From 1fa244c6948abe5524a1a295ab184e2194fdaed9 Mon Sep 17 00:00:00 2001 From: Hsukqi Lee Date: Tue, 10 Nov 2026 19:09:04 +0800 Subject: [PATCH 1/6] fix: return latest conversation messages by default Preserve explicit offset history pagination and cover message ordering, authorization, and worker replies. Refs #5. --- backend/app/web/api.py | 8 ++- backend/tests/test_web_platform.py | 102 ++++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/backend/app/web/api.py b/backend/app/web/api.py index d87650d..5a4f4d6 100644 --- a/backend/app/web/api.py +++ b/backend/app/web/api.py @@ -468,9 +468,13 @@ def owned_conversation(cid, user): return row @api.get("/conversations/{cid}/messages") - def messages(cid: str, user: User, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)): + def messages(cid: str, request: Request, user: User, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)): owned_conversation(cid, user) - rows = store.all("SELECT * FROM messages WHERE conversation_id=? ORDER BY created,id LIMIT ? OFFSET ?", (cid, limit, offset)) + if "offset" in request.query_params: + rows = store.all("SELECT * FROM messages WHERE conversation_id=? ORDER BY created,id LIMIT ? OFFSET ?", (cid, limit, offset)) + else: + rows = store.all("SELECT * FROM messages WHERE conversation_id=? ORDER BY created DESC,id DESC LIMIT ?", (cid, limit)) + rows.reverse() for row in rows: row["evidence"] = json.loads(row["evidence"]) return {"items": rows} diff --git a/backend/tests/test_web_platform.py b/backend/tests/test_web_platform.py index 7daf867..bce133a 100644 --- a/backend/tests/test_web_platform.py +++ b/backend/tests/test_web_platform.py @@ -1,13 +1,15 @@ import json import sqlite3 +import threading import time from concurrent.futures import ThreadPoolExecutor +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import pytest from fastapi.testclient import TestClient from app.web.api import create_web_app -from app.web.auth import create_user +from app.web.auth import create_user, login from app.web.store import Store from app.web.worker import execute @@ -154,6 +156,104 @@ def test_paginated_summaries_and_immutable_versions(setup): assert forbidden not in listing["items"][0] +@pytest.mark.parametrize("count", [0, 1, 49, 50, 51, 60, 121]) +def test_messages_default_latest_window_and_explicit_history(tmp_path, monkeypatch, count): + # Gap: the desk omits offset, so a historical first page hides new messages. + monkeypatch.setenv("FASTREAD_COOKIE_SECURE", "false") + app = create_web_app(tmp_path) + store = app.state.store + user = create_user(store, "window@example.test", "disposable-password") + other = create_user(store, "other@example.test", "disposable-password") + client = TestClient(app) + token, csrf = login(store, user["email"], "disposable-password", "127.0.0.1") + client.cookies.set("fastread_session", token) + client.headers["X-CSRF-Token"] = csrf + paper = store.ingest(user["workspace_id"], document()) + cid = client.post(f"/api/papers/{paper['paper_id']}/conversations").json()["id"] + path = f"/api/conversations/{cid}/messages" + ids = [f"m{i:03}" for i in range(count)] + with store.connect(write=True) as db: + # Reverse insertion and tied timestamps expose incomplete ordering keys. + db.executemany("INSERT INTO messages VALUES(?,?,?,?,?,?,?)", [ + (mid, cid, "assistant", mid, '{"sources":[]}', None, i // 4) + for i, mid in reversed(list(enumerate(ids))) + ]) + db.execute("INSERT INTO providers VALUES(?,?,?,?,?,?,?)", ( + "window-provider", user["workspace_id"], "Fixture", "https://example.test/v1", + "unused", '["window-model"]', time.time())) + windows = {} + for query, expected in [ + ("", ids[-50:]), ("?limit=10", ids[-10:]), + ("?limit=1", ids[-1:]), ("?limit=100", ids[-100:]), + ("?offset=0", ids[:50]), ("?limit=10&offset=0", ids[:10]), + ("?limit=10&offset=50", ids[50:60]), (f"?offset={count}", []), + ]: + response = client.get(path + query) + assert response.status_code == 200 + items = response.json()["items"] + assert [row["id"] for row in items] == expected, query + assert all(row["evidence"] == {"sources": []} for row in items) + windows[query or "default"] = [row["id"] for row in items] + for query in ["?limit=0", "?limit=101", "?offset=-1", "?offset="]: + assert client.get(path + query).status_code == 422 + sent = client.post(path, json={"content": "latest question", "provider_id": "window-provider", + "model": "window-model"}, headers={"Idempotency-Key": "latest"}) + assert sent.status_code == 202 + for _ in range(2): + assert client.get(path).json()["items"][-1]["content"] == "latest question" + if count == 60: + requests = [] + class ModelHandler(BaseHTTPRequestHandler): + def do_POST(self): + requests.append(json.loads(self.rfile.read(int(self.headers["Content-Length"])))) + completion = {"answer": "Latest grounded answer", "citations": [ + {"page": 1, "exact_quote": document()["pages"][0]["text"]}]} + body = json.dumps({"id": "fixture", "object": "chat.completion", "created": 1, + "model": "window-model", "choices": [{"index": 0, "finish_reason": "stop", + "message": {"role": "assistant", "content": json.dumps(completion)}}]}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + server = ThreadingHTTPServer(("127.0.0.1", 0), ModelHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + monkeypatch.setenv("CHAT_VECTOR_INDEX_ENABLED", "false") + with store.connect(write=True) as db: + db.execute("UPDATE providers SET base_url=? WHERE id='window-provider'", + (f"http://127.0.0.1:{server.server_port}/v1",)) + claimed = store.claim("window-worker") + assert claimed["id"] == sent.json()["id"] + execute(store, claimed, "window-worker") + assert len(requests) == 1 + assert requests[0]["messages"][-1]["content"] == "latest question" + assert client.get(f"/api/jobs/{claimed['id']}").json()["state"] == "succeeded" + for _ in range(2): + latest = client.get(path).json()["items"] + assert len(latest) == 50 + assert [row["role"] for row in latest[-2:]] == ["user", "assistant"] + assert latest[-1]["content"] == "Latest grounded answer" + assert latest[-1]["evidence"]["sources"][0]["exact_quote"] == document()["pages"][0]["text"] + finally: + server.shutdown(); server.server_close(); thread.join(timeout=5) + assert client.get("/api/conversations/missing/messages").status_code == 404 + assert TestClient(app).get(path).status_code == 401 + unrelated = store.ingest(user["workspace_id"], document() | {"doi": "10.1234/other"}) + unrelated_cid = client.post(f"/api/papers/{unrelated['paper_id']}/conversations").json()["id"] + for query in ["", "?offset=0"]: + assert client.get(f"/api/conversations/{unrelated_cid}/messages" + query).json()["items"] == [] + other_token, _ = login(store, other["email"], "disposable-password", "127.0.0.1") + client.cookies.set("fastread_session", other_token) + for query in ["", "?offset=0"]: + assert client.get(path + query).status_code == 404 + (tmp_path / "message-window.json").write_text(json.dumps({ + "initial_count": count, "windows": windows, "posted_question_visible": True, + "worker_answer_verified": count == 60, "anonymous": 401, "other_workspace": 404, + }, sort_keys=True, indent=2), encoding="utf-8") + + def test_atomic_dedupe_and_concurrency_limits(setup): store, a, b, _, _ = setup def submit(_): From c9c399ef8f1a046bbb6a367d806ac39c7e685b1c Mon Sep 17 00:00:00 2001 From: Hsukqi Lee Date: Wed, 11 Nov 2026 06:05:52 +0800 Subject: [PATCH 2/6] refactor: share UI SDK and verified paper intake contracts Use immutable public release artifacts and retain product-specific behavior. Independent review and real release installation recorded in the sharing handoff. --- .../workflows/fastcas-release-contract.yml | 40 +++ Dockerfile.complete | 6 + backend/Dockerfile | 6 + backend/Dockerfile.gpu | 6 + backend/tests/fastcas_release_contract.py | 164 ++++++++++++ backend/tests/test_research_intake.py | 50 +++- contracts/fastnews-intake/v1/README.md | 34 +++ contracts/fastnews-intake/v1/cases.json | 30 +++ .../fastnews-intake/v1/request.schema.json | 16 ++ .../fastnews-intake/v1/response.schema.json | 12 + docs/FASTCAS.md | 16 +- fastread-frontend/.gitignore | 1 + fastread-frontend/package.json | 7 +- fastread-frontend/pnpm-lock.yaml | 85 ++++-- .../scripts/fastnews-browser.mjs | 244 ++++++++++++++++++ .../scripts/fastnews-receiver.py | 23 ++ fastread-frontend/scripts/fastui-browser.mjs | 95 +++++++ fastread-frontend/src/main.tsx | 6 +- fastread-frontend/src/web/App.tsx | 44 +++- fastread-frontend/src/web/FastCASAccount.tsx | 23 +- fastread-frontend/src/web/LocalLogin.tsx | 3 +- fastread-frontend/src/web/settings.css | 22 ++ fastread-frontend/src/web/sso-bootstrap.ts | 219 +++++++++------- fastread-frontend/src/web/theme.ts | 4 + requirements-fastcas.txt | 4 +- 25 files changed, 1006 insertions(+), 154 deletions(-) create mode 100644 .github/workflows/fastcas-release-contract.yml create mode 100644 backend/tests/fastcas_release_contract.py create mode 100644 contracts/fastnews-intake/v1/README.md create mode 100644 contracts/fastnews-intake/v1/cases.json create mode 100644 contracts/fastnews-intake/v1/request.schema.json create mode 100644 contracts/fastnews-intake/v1/response.schema.json create mode 100644 fastread-frontend/scripts/fastnews-browser.mjs create mode 100644 fastread-frontend/scripts/fastnews-receiver.py create mode 100644 fastread-frontend/scripts/fastui-browser.mjs create mode 100644 fastread-frontend/src/web/settings.css create mode 100644 fastread-frontend/src/web/theme.ts diff --git a/.github/workflows/fastcas-release-contract.yml b/.github/workflows/fastcas-release-contract.yml new file mode 100644 index 0000000..0083fb7 --- /dev/null +++ b/.github/workflows/fastcas-release-contract.yml @@ -0,0 +1,40 @@ +name: FastCAS published SDK contract +on: + pull_request: + push: + branches: [main, master] + tags: ['v*'] + workflow_dispatch: +permissions: + contents: read +jobs: + cas-enabled: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + CHAT_VECTOR_INDEX_ENABLED: 'false' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: | + backend/requirements.txt + backend/requirements-dev.txt + requirements-fastcas.txt + - run: python -m pip install -r backend/requirements-dev.txt -r requirements-fastcas.txt + - run: python -m pip check + - name: Verify published wheel identity and adapters + run: python -I -c "from importlib.metadata import version; from fastcas import FastCAS; from fastcas.fastapi import receive_event, receive_logout; assert version('fastcas-sdk') == '0.1.1'; print(version('fastcas-sdk'))" + - name: Real SDK and product HTTP routes + run: python -m pytest --noconftest backend/tests/fastcas_release_contract.py backend/tests/test_research_intake.py backend/tests/test_fastcas_store.py backend/tests/test_fastcas_service.py backend/tests/test_local_accounts.py + cas-image: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Build CAS-enabled backend from this single checkout + run: docker build -f backend/Dockerfile --build-arg PYTHON_BASE_IMAGE=python:3.11-slim --build-arg APT_MIRROR=deb.debian.org --build-arg PIP_INDEX=https://pypi.org/simple --build-arg FASTCAS_ENABLED=true -t fastread-cas:contract . + - name: Verify installed runtime SDK without starting application services + run: docker run --rm --network none fastread-cas:contract python -I -c "from importlib.metadata import version; from fastcas.fastapi import receive_event, receive_logout; assert version('fastcas-sdk') == '0.1.1'" diff --git a/Dockerfile.complete b/Dockerfile.complete index 4a48b26..d6a867b 100644 --- a/Dockerfile.complete +++ b/Dockerfile.complete @@ -11,6 +11,12 @@ WORKDIR /tmp/backend # 先复制 requirements.txt 利用层缓存 COPY ./backend/requirements.txt /tmp/backend/requirements.txt RUN pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt +COPY ./requirements-fastcas.txt /tmp/backend/requirements-fastcas.txt +ARG FASTCAS_ENABLED=false +RUN if [ "$FASTCAS_ENABLED" = "true" ]; then \ + pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt -r requirements-fastcas.txt && \ + python -c "from fastcas import FastCAS; from fastcas.fastapi import receive_event, receive_logout"; \ + fi COPY ./backend /tmp/backend diff --git a/backend/Dockerfile b/backend/Dockerfile index 056fcb1..e602313 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -21,6 +21,12 @@ WORKDIR /app # 先复制 requirements.txt 利用层缓存 COPY ./backend/requirements.txt /app/requirements.txt RUN pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt +COPY ./requirements-fastcas.txt /app/requirements-fastcas.txt +ARG FASTCAS_ENABLED=false +RUN if [ "$FASTCAS_ENABLED" = "true" ]; then \ + pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt -r requirements-fastcas.txt && \ + python -c "from fastcas import FastCAS; from fastcas.fastapi import receive_event, receive_logout"; \ + fi # 再复制应用代码(频繁变动不影响 pip 缓存层) COPY ./backend /app diff --git a/backend/Dockerfile.gpu b/backend/Dockerfile.gpu index 7f2aaae..c8e7c38 100644 --- a/backend/Dockerfile.gpu +++ b/backend/Dockerfile.gpu @@ -20,6 +20,12 @@ WORKDIR /app COPY ./backend/requirements.txt /app/requirements.txt RUN pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt && \ pip install --no-cache-dir -i ${PIP_INDEX} 'transformers[torch]>=4.23' +COPY ./requirements-fastcas.txt /app/requirements-fastcas.txt +ARG FASTCAS_ENABLED=false +RUN if [ "$FASTCAS_ENABLED" = "true" ]; then \ + pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt -r requirements-fastcas.txt && \ + python3 -c "from fastcas import FastCAS; from fastcas.fastapi import receive_event, receive_logout"; \ + fi # 再复制应用代码 COPY ./backend /app diff --git a/backend/tests/fastcas_release_contract.py b/backend/tests/fastcas_release_contract.py new file mode 100644 index 0000000..3d9fbef --- /dev/null +++ b/backend/tests/fastcas_release_contract.py @@ -0,0 +1,164 @@ +"""Explicit CAS-enabled gate: installed wheel -> real routes -> SQLite. + +Not collected by the local-mode suite, which intentionally has no optional SDK. +CI invokes this file by name; missing SDK/dependencies is an error, never a skip. +The loopback OIDC peer is disposable, not a deployed FastCAS integration claim. +""" +import base64 +import hashlib +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from importlib.metadata import version +from urllib.parse import parse_qs, urlencode, urlparse + +from authlib.jose import JsonWebKey, jwt +from fastapi.testclient import TestClient + +from app.web.api import create_web_app +from app.web.auth import create_user + + +def test_published_sdk_link_login_sid_logout_and_signed_revocation(tmp_path, monkeypatch): + assert version('fastcas-sdk') == '0.1.1' + key = JsonWebKey.generate_key('RSA', 2048, {'kid': 'fixture'}, is_private=True) + peer = {'codes': {}, 'counter': 0, 'link': None} + read_origin = 'http://127.0.0.1:8483' + + def sign(claims, typ='JWT'): + return jwt.encode({'alg': 'RS256', 'kid': 'fixture', 'typ': typ}, + {'iss': issuer, 'aud': 'read', 'sub': 'subject-a', + 'iat': int(time.time()), 'exp': int(time.time()) + 120, **claims}, key).decode() + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def reply(self, data, status=200): + self.send_response(status) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps(data).encode()) + + def do_GET(self): + path = urlparse(self.path).path + if path == '/.well-known/openid-configuration': + self.reply({'issuer': issuer, 'authorization_endpoint': issuer + '/authorize', + 'token_endpoint': issuer + '/token', 'jwks_uri': issuer + '/jwks', + 'userinfo_endpoint': issuer + '/userinfo'}) + elif path == '/jwks': + self.reply({'keys': [key.as_dict(is_private=False)]}) + elif path == '/userinfo': + self.reply({'sub': 'subject-a', 'email': 'local@example.org'}) + elif path == '/api/v1/account-links/resolve': + self.reply(peer['link']) + else: + self.reply({'error': 'not_found'}, 404) + + def do_POST(self): + raw = self.rfile.read(int(self.headers.get('Content-Length', '0'))) + if self.path == '/token': + form = parse_qs(raw.decode()) + grant = peer['codes'].pop(form.get('code', [''])[0], None) + verifier = form.get('code_verifier', [''])[0] + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b'=').decode() + if not grant or challenge != grant['code_challenge'][0] or form.get('grant_type') != ['authorization_code']: + self.reply({'error': 'invalid_grant'}, 400) + return + peer['counter'] += 1 + self.reply({'access_token': 'disposable-access', 'token_type': 'Bearer', 'expires_in': 120, + 'id_token': sign({'nonce': grant['nonce'][0], 'sid': 'sid-' + str(peer['counter'])})}) + elif self.path == '/api/v1/link-intents': + body = json.loads(raw) + peer['local_ref'] = body['local_account_ref'] + self.reply({'id': 'intent-a', 'nonce': 'intent-nonce', 'client_id': 'read', + 'local_account_ref': body['local_account_ref'], 'state': 'pending', + 'expires_at': '2030-01-01T00:00:00Z'}) + elif self.path == '/api/v1/link-intents/intent-a/prepare': + body = json.loads(raw) + if body != {'nonce': 'intent-nonce', 'subject': 'subject-a'}: + self.reply({'error': 'invalid_intent'}, 400) + return + peer['link'] = {'id': 'link-a', 'client_id': 'read', 'subject': 'subject-a', + 'local_account_ref': peer['local_ref'], 'state': 'prepared', + 'version': 1, 'verified_at': '2026-11-10T00:00:00Z'} + self.reply(peer['link']) + elif self.path == '/api/v1/link-intents/link-a/activate': + peer['link'] = {**peer['link'], 'state': 'active', 'version': 2} + self.reply(peer['link']) + else: + self.reply({'error': 'not_found'}, 404) + + server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) + issuer = f'http://127.0.0.1:{server.server_port}' + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + for suffix, value in {'ISSUER': issuer, 'CLIENT_ID': 'read', 'CLIENT_SECRET': 'disposable-secret', + 'REDIRECT_URI': read_origin + '/api/auth/fastcas/callback', + 'ALLOW_LOOPBACK_HTTP': 'true'}.items(): + monkeypatch.setenv('FASTREAD_FASTCAS_' + suffix, value) + monkeypatch.setenv('FASTREAD_COOKIE_SECURE', 'false') + monkeypatch.setenv('CHAT_VECTOR_INDEX_ENABLED', 'false') + app = create_web_app(tmp_path) + service = app.state.fastcas + + def finish(client, location): + query = parse_qs(urlparse(location).query) + code = 'code-' + str(len(peer['codes'])) + '-' + str(peer['counter']) + peer['codes'][code] = query + callback = '/api/auth/fastcas/callback?' + urlencode({'code': code, 'state': query['state'][0]}) + response = client.get(callback, follow_redirects=False) + assert response.status_code == 302 and response.headers['location'].endswith('fastcas=complete') + return callback + + def cas_login(): + client = TestClient(app, base_url=read_origin) + response = client.get('/api/auth/fastcas/login', follow_redirects=False) + assert response.status_code == 302 + callback = finish(client, response.headers['location']) + assert client.get('/api/auth/me').status_code == 200 + assert client.get(callback, follow_redirects=False).headers['location'].endswith('fastcas=failed') + return client + + try: + user = create_user(app.state.store, 'local@example.org', 'original-password') + local = TestClient(app, base_url=read_origin) + preauth = local.get('/api/auth/session-init').json()['csrf'] + result = local.post('/api/auth/login', json={'email': user['email'], 'password': 'original-password'}, headers={'X-CSRF-Token': preauth}) + assert result.status_code == 200 + link = local.post('/api/auth/fastcas/link', json={'password': 'original-password'}, + headers={'Origin': read_origin, 'X-CSRF-Token': result.json()['csrf']}) + assert link.status_code == 200 + finish(local, link.json()['url']) + assert local.get('/api/auth/me').status_code == 200 + first, second = cas_login(), cas_login() + notify = TestClient(app, base_url=read_origin) + notice = {'jti': 'logout-one', 'sid': 'sid-2', + 'events': {'http://schemas.openid.net/event/backchannel-logout': {}}} + invalid = notify.post('/api/auth/fastcas/backchannel-logout', data={'logout_token': sign({**notice, 'aud': 'other'}, 'logout+jwt')}) + assert invalid.status_code == 401 + assert first.get('/api/auth/me').status_code == 200 + token = sign(notice, 'logout+jwt') + for _ in range(2): + assert notify.post('/api/auth/fastcas/backchannel-logout', data={'logout_token': token}).status_code == 200 + assert first.get('/api/auth/me').status_code == 401 + assert second.get('/api/auth/me').status_code == 200 + assert local.get('/api/auth/me').status_code == 200 + global_notice = {'jti': 'logout-all', 'events': notice['events']} + assert notify.post('/api/auth/fastcas/backchannel-logout', data={'logout_token': sign(global_notice, 'logout+jwt')}).status_code == 200 + assert second.get('/api/auth/me').status_code == 401 + third = cas_login() + revoked = {**peer['link'], 'state': 'revoked', 'version': 3} + event = sign({'jti': 'revoke-one', 'event': {'type': 'account_link.revoked', 'link': revoked}}, 'fastcas-event+jwt') + for _ in range(2): + assert notify.post('/api/auth/fastcas/events', content=event, headers={'Content-Type': 'application/jwt'}).status_code == 200 + assert third.get('/api/auth/me').status_code == 401 + assert local.get('/api/auth/me').status_code == 200 + assert local.get('/api/auth/me').json()['workspace_id'] == user['workspace_id'] + finally: + if service._sdk is not None: + service.sdk.close() + server.shutdown() + server.server_close() + thread.join(timeout=5) diff --git a/backend/tests/test_research_intake.py b/backend/tests/test_research_intake.py index c366426..382443b 100644 --- a/backend/tests/test_research_intake.py +++ b/backend/tests/test_research_intake.py @@ -1,6 +1,10 @@ from fastapi import HTTPException from fastapi.testclient import TestClient from tests.test_web_platform import setup, document +import json +from pathlib import Path + +CONTRACT = json.loads((Path(__file__).resolve().parents[2] / 'contracts/fastnews-intake/v1/cases.json').read_text(encoding='utf-8')) def test_sso_rotates_session_and_preserves_workspace(setup): @@ -31,12 +35,19 @@ def reject(*args, **kwargs): def test_fastnews_intake_pdf_attachment_and_workspace_isolation(setup): store, a, b, ca, cb = setup - data = {'title': 'A metadata-only paper', 'authors': 'Alice', 'abstract': 'A recorded abstract', - 'url': 'https://example.org/paper', 'external_id': 'fixture-1', 'year': '2026'} + data = CONTRACT['intake'] first = ca.post('/api/integrations/fastnews/intake', json=data) assert first.status_code in (200, 201) + assert all(first.json()[key] == value for key, value in CONTRACT['first_receipt'].items()) pid = first.json()['id'] - assert ca.post('/api/integrations/fastnews/intake', json=data).json()['id'] == pid + assert pid and first.json()['title'] == data['title'] + repeated = ca.post('/api/integrations/fastnews/intake', json=data).json() + assert repeated['id'] == pid + assert all(repeated[key] == value for key, value in CONTRACT['repeat_receipt'].items()) + updated = ca.post('/api/integrations/fastnews/intake', json={**data, 'title': 'Updated title'}) + assert updated.json()['id'] == pid and updated.json()['created'] is False + assert ca.get(f'/api/papers/{pid}').json()['title'] == 'Updated title' + assert ca.post('/api/integrations/fastnews/intake', json=data).json()['title'] == data['title'] assert cb.post('/api/integrations/fastnews/intake', json=data).json()['id'] != pid assert ca.get(f'/api/papers/{pid}/pages/1').status_code == 409 assert cb.get(f'/api/papers/{pid}').status_code == 404 @@ -50,3 +61,36 @@ def test_fastnews_intake_pdf_attachment_and_workspace_isolation(setup): assert result['paper_id'] == pid paper = ca.get(f'/api/papers/{pid}').json() assert paper['active_version'] and paper['title'] == data['title'] + again = ca.post('/api/integrations/fastnews/intake', json=data) + assert again.status_code == 200 and again.json()['awaiting_file'] is False + assert ca.get(f'/api/papers/{pid}').json()['active_version'] == paper['active_version'] + + +def test_intake_wire_errors_and_normalization(setup): + _, _, _, ca, _ = setup + endpoint = '/api/integrations/fastnews/intake' + anonymous = TestClient(ca.app) + assert anonymous.post(endpoint, json=CONTRACT['intake']).status_code == 401 + assert ca.post(endpoint, json=CONTRACT['intake'], headers={'X-CSRF-Token': 'wrong'}).status_code == 403 + for case in CONTRACT['invalid']: + assert ca.post(endpoint, json=case['body']).status_code == case['status'] + for field, limit in [('title', 300), ('authors', 400), ('abstract', 800), ('venue', 80), ('year', 8), ('external_id', 200)]: + assert ca.post(endpoint, json={**CONTRACT['intake'], field: 'x' * (limit + 1)}).status_code == 422 + for url in CONTRACT['url_normalization']: + response = ca.post(endpoint, json={**CONTRACT['intake'], 'url': url}) + assert response.status_code == 200 + paper = ca.get('/api/papers/' + response.json()['id']).json() + assert paper['metadata']['url'] == '' + + +def test_intake_without_external_id_uses_url_then_title_deduplication(setup): + _, _, _, ca, _ = setup + endpoint = '/api/integrations/fastnews/intake' + for payload in [{'title': 'URL identity', 'url': 'https://example.org/keyless'}, {'title': 'Title identity'}]: + first = ca.post(endpoint, json=payload) + assert first.status_code == 200 and first.json()['created'] is True + repeated = ca.post(endpoint, json={**payload, 'authors': 'Updated author'}) + assert repeated.status_code == 200 + assert repeated.json()['id'] == first.json()['id'] and repeated.json()['created'] is False + paper = ca.get('/api/papers/' + first.json()['id']).json() + assert paper['metadata']['authors'] == ['Updated author'] diff --git a/contracts/fastnews-intake/v1/README.md b/contracts/fastnews-intake/v1/README.md new file mode 100644 index 0000000..9b46d25 --- /dev/null +++ b/contracts/fastnews-intake/v1/README.md @@ -0,0 +1,34 @@ +# FastNews → FastRead intake v1 + +Owner: FastRead. Receiver: `POST /api/integrations/fastnews/intake` with a +FastRead session cookie and matching `X-CSRF-Token`. Anonymous=401, invalid +CSRF=403, invalid title/oversized normalized text=422, success=200. Receipt is +metadata persistence, not a PDF download or automatic job submission. + +Schemas cover canonical intake and receipt. `cases.json` records existing wire +semantics, including URL sanitation (invalid URLs become empty, not 422). +Same workspace + external_id deduplicates, else URL hash, else title hash. +Duplicates update metadata and return created=false. Different workspaces are +isolated. A subsequent PDF attachment retains the paper ID and active_version; +another metadata intake must not clear that version. There is no intake +Idempotency-Key contract or cross-workspace shared identity. + +## Browser handoff boundary for the UI owner + +News `serve.py:build_fastread_handoff` currently returns +`{readBase}/?sso=#fastnews=`; without a ticket, +`{readBase}/#fastnews=...`. readBase may include `/read`. Decode the fragment +once, normalize `handoff.id` to `intake.external_id`, and send only canonical +intake fields. DOI is present in News' carrier but is not an intake API field. +Do not claim DOI was stored. The fixture deliberately keeps both forms visible. + +Keep a pending handoff across ticket consumption and local-login UI, clear the +ticket URL promptly, and clear the pending payload only after an authenticated, +CSRF-authorized successful receipt. Failed 401/403/422/network requests must not +display success. Root/subpath carrier decoding and login restoration need a real +browser scenario in the UI change. The SDK/CI work does not edit frontend source. + +Run: `python -m pytest --noconftest backend/tests/test_research_intake.py`. +The HTTP contract test reads this fixture directly; News is the confirmed sending +product. A second independent sending product has not been identified. No shared +generated client or FastCommon package is introduced. diff --git a/contracts/fastnews-intake/v1/cases.json b/contracts/fastnews-intake/v1/cases.json new file mode 100644 index 0000000..b77bb93 --- /dev/null +++ b/contracts/fastnews-intake/v1/cases.json @@ -0,0 +1,30 @@ +{ + "handoff": { + "title": "共享契约 / scientific evidence", + "source": "fastnews", + "authors": "Alice; Bob", + "abstract": "A synthetic abstract.", + "url": "https://example.org/paper", + "venue": "Fixture Conference", + "year": "2026", + "id": "news-fixture-1", + "doi": "10.1234/fixture" + }, + "intake": { + "title": "共享契约 / scientific evidence", + "authors": "Alice; Bob", + "abstract": "A synthetic abstract.", + "url": "https://example.org/paper", + "venue": "Fixture Conference", + "year": "2026", + "external_id": "news-fixture-1" + }, + "first_receipt": {"awaiting_file": true, "created": true}, + "repeat_receipt": {"awaiting_file": true, "created": false}, + "invalid": [ + {"body": {}, "status": 422}, + {"body": {"title": " "}, "status": 422}, + {"body": {"title": null}, "status": 422} + ], + "url_normalization": ["javascript:alert(1)", "https://user:secret@example.org/paper", "not-a-url"] +} diff --git a/contracts/fastnews-intake/v1/request.schema.json b/contracts/fastnews-intake/v1/request.schema.json new file mode 100644 index 0000000..a947da6 --- /dev/null +++ b/contracts/fastnews-intake/v1/request.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "FastRead FastNews normalized intake", + "type": "object", + "required": ["title"], + "properties": { + "title": {"type": "string", "minLength": 1, "maxLength": 300, "pattern": "\\S"}, + "authors": {"type": "string", "maxLength": 400, "default": ""}, + "abstract": {"type": "string", "maxLength": 800, "default": ""}, + "url": {"type": "string", "maxLength": 2048, "default": ""}, + "venue": {"type": "string", "maxLength": 80, "default": ""}, + "year": {"type": "string", "maxLength": 8, "default": ""}, + "external_id": {"type": "string", "maxLength": 200, "default": ""} + }, + "description": "Canonical input after text whitespace normalization. Raw text fields are stringified, null becomes empty. Invalid/non-HTTP(S)/credential-bearing URLs become empty; URLs truncate to 2048. Extra fields are ignored, including id and doi: browser handoff must map id to external_id." +} diff --git a/contracts/fastnews-intake/v1/response.schema.json b/contracts/fastnews-intake/v1/response.schema.json new file mode 100644 index 0000000..ddaed7a --- /dev/null +++ b/contracts/fastnews-intake/v1/response.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "FastRead FastNews intake receipt", + "type": "object", + "required": ["id", "title", "awaiting_file", "created"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "title": {"type": "string"}, + "awaiting_file": {"type": "boolean"}, + "created": {"type": "boolean"} + } +} diff --git a/docs/FASTCAS.md b/docs/FASTCAS.md index a70205e..b92fdb3 100644 --- a/docs/FASTCAS.md +++ b/docs/FASTCAS.md @@ -4,7 +4,9 @@ ## 开发配置 -在 FastRead 根目录运行 `python -m pip install -r requirements-fastcas.txt`,使用同级 FastCAS 的 Python SDK。部署发布版 SDK 的安装方式待统一发布工作完成。本地模式不需要 SDK,不会访问 FastCAS。 +在 FastRead 根目录运行 `python -m pip install -r backend/requirements.txt -r requirements-fastcas.txt`,从 PyPI 安装固定发行版 `fastcas-sdk[fastapi]==0.1.1`(导入名 `fastcas`)。本地模式不需要 SDK,不会访问 FastCAS。 + +三个后端镜像入口 `backend/Dockerfile`、`backend/Dockerfile.gpu`、`Dockerfile.complete` 均通过 `--build-arg FASTCAS_ENABLED=true` 安装同一可选依赖并检查 FastAPI 适配层导入。例如在仓库根执行 `docker build -f backend/Dockerfile --build-arg FASTCAS_ENABLED=true -t fastread-cas:local .`。默认构建保留无 SDK 的本地模式;运行时启用下面四项连接配置前必须选择 CAS-enabled 构建。Compose 用户可在本地 override 的 backend.build.args 中指定 `FASTCAS_ENABLED: "true"`,连接配置放入 backend.environment;这不是自动启用 CAS 登录的开关。 连接配置四项必须同时提供: @@ -29,6 +31,18 @@ ## 验证和剩余工作 +发行包门禁 `.github/workflows/fastcas-release-contract.yml` 在单仓 checkout +安装完整后端依赖与可选集,执行依赖兼容检查及 +`backend/tests/fastcas_release_contract.py`。该场景通过安装的 0.1.1 wheel +访问可丢弃的 HTTP OIDC 签名对端,并经过 FastRead 实际认证路由与 SQLite, +覆盖绑定、PKCE/nonce 回调、单次消费、SID/全局登出、签名撤销与本地会话保留。 +它验证发行包与产品 adapter,不代表部署中的 Go FastCAS 提供方联调。 +普通质量门仍只安装基础依赖;CAS 镜像 job 单独构建并检查运行时导入。 + +FastNews intake 的 receiver-owned wire 合同及浏览器交接边界见 +`contracts/fastnews-intake/v1/README.md`。HTTP intake 回归读取同目录 +`cases.json`,覆盖 CSRF、校验、去重、工作区隔离及已附 PDF 保留。 + `PYTHONPATH=backend python3 -m pytest --noconftest backend/tests/test_local_accounts.py backend/tests/test_fastcas_store.py backend/tests/test_fastcas_service.py -q`:16 项通过。跳过全局桌面数据库 fixture,仅验证账号和接入服务;不代表全仓库回归。 FastCAS 根目录运行 `FASTCAS_PROJECT_CONTRACT=1 go test ./internal/httpapi -run '^TestFastReadAgainstProvider$' -count=1 -v`,需要测试 PostgreSQL 和可导入 FastAPI、HTTPX、SDK 的 Python(可用 `FASTCAS_PROJECT_PYTHON` 指定)。已通过真实 Go 提供方 + PostgreSQL + Python SDK + FastRead 真实认证路由契约,覆盖同邮箱不合并、本地证明绑定、CAS 登录、回调重放拒绝、解除后保留本地会话和原论文工作区。 diff --git a/fastread-frontend/.gitignore b/fastread-frontend/.gitignore index 88b6244..62ca580 100644 --- a/fastread-frontend/.gitignore +++ b/fastread-frontend/.gitignore @@ -23,3 +23,4 @@ dist-ssr *.sln *.sw? /src-tauri/bin/ +/test-results/ diff --git a/fastread-frontend/package.json b/fastread-frontend/package.json index 7c572cd..afbbb29 100644 --- a/fastread-frontend/package.json +++ b/fastread-frontend/package.json @@ -11,9 +11,13 @@ "typecheck": "tsc -b --pretty false", "lint": "eslint .", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "test:fastui:browser": "node scripts/fastui-browser.mjs", + "test:fastnews:browser": "node scripts/fastnews-browser.mjs" }, "dependencies": { + "@fastrd/ui-foundation": "https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz", + "@fastrd/ui-react": "https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-react-0.1.0.tgz", "@ant-design/x": "1.6.1", "@hookform/resolvers": "^5.0.1", "@lobehub/icons": "^1.97.1", @@ -72,6 +76,7 @@ "zustand": "^5.0.3" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@eslint/js": "^9.21.0", "@tailwindcss/postcss": "^4.1.3", "@tauri-apps/cli": "2.5.0", diff --git a/fastread-frontend/pnpm-lock.yaml b/fastread-frontend/pnpm-lock.yaml index 6220403..f9f6f6e 100644 --- a/fastread-frontend/pnpm-lock.yaml +++ b/fastread-frontend/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@ant-design/x': specifier: 1.6.1 version: 1.6.1(antd@5.29.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@fastrd/ui-foundation': + specifier: https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz + version: https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz + '@fastrd/ui-react': + specifier: https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-react-0.1.0.tgz + version: https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-react-0.1.0.tgz(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@hookform/resolvers': specifier: ^5.0.1 version: 5.2.2(react-hook-form@7.72.0(react@19.2.4)) @@ -180,6 +186,9 @@ importers: '@eslint/js': specifier: ^9.21.0 version: 9.39.4 + '@playwright/test': + specifier: ^1.63.0 + version: 1.65.0 '@tailwindcss/postcss': specifier: ^4.1.3 version: 4.2.2 @@ -679,6 +688,17 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fastrd/ui-foundation@https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz': + resolution: {integrity: sha512-5O4s9BXXHXWk7GbzPfMYX6HjNi0DKryfWLRPYMZINNb4ew3arjCKbJvxFaSvqcCvhHxmrf8OZDg9GPmfvdnRtw==, tarball: https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz} + version: 0.1.0 + + '@fastrd/ui-react@https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-react-0.1.0.tgz': + resolution: {integrity: sha512-zsnN8Afm889TyhPX0ZQQ4hIn5Bj1EsFbEZ857CQXDgmCIOEfY3369bCFn1VEelR/EWjU5Lpyjj6D1E9lVtQ5uw==, tarball: https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-react-0.1.0.tgz} + version: 0.1.0 + peerDependencies: + react: ^18.3.0 || ^19.0.0 + react-dom: ^18.3.0 || ^19.0.0 + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -814,6 +834,11 @@ packages: '@mermaid-js/parser@1.0.1': resolution: {integrity: sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==} + '@playwright/test@1.65.0': + resolution: {integrity: sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==} + engines: {node: '>=20'} + hasBin: true + '@primer/octicons@19.23.1': resolution: {integrity: sha512-CzjGmxkmNhyst6EekrS3SJPdtzgIkUMP/LSJch65y99/kmiFXbO1a+q7zoYe3hnI9NaOM0IN+ydDIbOmd8YqcA==} @@ -1313,79 +1338,66 @@ packages: resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.0': resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.0': resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.0': resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.0': resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.0': resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.0': resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.0': resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.0': resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.0': resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.0': resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.0': resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.0': resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.0': resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} @@ -1490,28 +1502,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.2': resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.2': resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.2': resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.2': resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} @@ -1575,35 +1583,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.5.0': resolution: {integrity: sha512-rQO1HhRUQqyEaal5dUVOQruTRda/TD36s9kv1hTxZiFuSq3558lsTjAcUEnMAtBcBkps20sbyTJNMT0AwYIk8Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.5.0': resolution: {integrity: sha512-7oS18FN46yDxyw1zX/AxhLAd7T3GrLj3Ai6s8hZKd9qFVzrAn36ESL7d3G05s8wEtsJf26qjXnVF4qleS3dYsA==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.5.0': resolution: {integrity: sha512-SG5sFNL7VMmDBdIg3nO3EzNRT306HsiEQ0N90ILe3ZABYAVoPDO/ttpCO37ApLInTzrq/DLN+gOlC/mgZvLw1w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.5.0': resolution: {integrity: sha512-QXDM8zp/6v05PNWju5ELsVwF0VH1n6b5pk2E6W/jFbbiwz80Vs1lACl9pv5kEHkrxBj+aWU/03JzGuIj2g3SkQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.5.0': resolution: {integrity: sha512-pFSHFK6b+o9y4Un8w0gGLwVyFTZaC3P0kQ7umRt/BLDkzD5RnQ4vBM7CF8BCU5nkwmEBUCZd7Wt3TWZxe41o6Q==} @@ -2555,6 +2558,7 @@ packages: eslint@9.39.4: resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -3137,28 +3141,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -3288,24 +3288,29 @@ packages: markmap-common@0.18.9: resolution: {integrity: sha512-MV2HQO7IGIm3jWEJXSG8vmdpqf4WIDXcEyAEN52lrWR1qD53Zg5l81JwjXoZ2l0rY5mofKYqUFlmdM2fqTGMVg==} + deprecated: Replaced by @markmap/* in 1.0.0. See https://markmap.js.org/docs/migration markmap-html-parser@0.18.11: resolution: {integrity: sha512-+kC5C4sCGntGUhGvTa5VIb5rtM75cSy/VCy3tzZoNAcn2qZGdgYvljN0WvjsOzrEzp+V6XKgwzO0u2TdzNAiOg==} + deprecated: Replaced by @markmap/* in 1.0.0. See https://markmap.js.org/docs/migration peerDependencies: markmap-common: '*' markmap-lib@0.18.12: resolution: {integrity: sha512-WCA4OT+b71jYg0e4PS/6NRKqihod5OpPsvw1jEGHQwCtqQrY/yXXCeRyuL3axOS5cMy5pV8BSl4CwKfJU1LxJg==} + deprecated: Replaced by @markmap/* in 1.0.0. See https://markmap.js.org/docs/migration peerDependencies: markmap-common: '*' markmap-toolbar@0.18.12: resolution: {integrity: sha512-+MQ/95ywl/ZLhVKb0tKjRa3p/AuUSYsuJRs1MPHmkxcxBaumXgzLNHUUnLjKmsbNxeZUSQGe3QJaFyRQYcHZJg==} + deprecated: Replaced by @markmap/* in 1.0.0. See https://markmap.js.org/docs/migration peerDependencies: markmap-common: '*' markmap-view@0.18.12: resolution: {integrity: sha512-D8bzT1YwIC/8rkbwm6WzigVUrpOAGv7ioEGTi1Lj+Oo8gO5sAm6hhli27jvTgUcZ9TwBeIWZ+dSUP+AupYUGlQ==} + deprecated: Replaced by @markmap/* in 1.0.0. See https://markmap.js.org/docs/migration peerDependencies: markmap-common: '*' @@ -3790,6 +3795,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.65.0: + resolution: {integrity: sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.65.0: + resolution: {integrity: sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==} + engines: {node: '>=20'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -5545,6 +5560,14 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fastrd/ui-foundation@https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz': {} + + '@fastrd/ui-react@https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-react-0.1.0.tgz(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@fastrd/ui-foundation': https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -5830,6 +5853,10 @@ snapshots: dependencies: langium: 4.2.1 + '@playwright/test@1.65.0': + dependencies: + playwright: 1.65.0 + '@primer/octicons@19.23.1': dependencies: object-assign: 4.1.1 @@ -9510,6 +9537,12 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 + playwright-core@1.65.0: {} + + playwright@1.65.0: + dependencies: + playwright-core: 1.65.0 + points-on-curve@0.2.0: {} points-on-path@0.2.1: diff --git a/fastread-frontend/scripts/fastnews-browser.mjs b/fastread-frontend/scripts/fastnews-browser.mjs new file mode 100644 index 0000000..c08e77e --- /dev/null +++ b/fastread-frontend/scripts/fastnews-browser.mjs @@ -0,0 +1,244 @@ +import { chromium, expect } from '@playwright/test' +import { preview } from 'vite' +import { createServer } from 'node:http' +import { spawn, spawnSync } from 'node:child_process' +import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import assert from 'node:assert/strict' + +const python = process.env.FASTREAD_TEST_PYTHON +if (!python) throw new Error('Set FASTREAD_TEST_PYTHON to a Python with Read backend requirements and News werkzeug installed.') +const contract = JSON.parse(await readFile('../contracts/fastnews-intake/v1/cases.json', 'utf8')) +const news = resolve('../../fast-news/serve.py') +const artifacts = 'test-results/fastnews' +await mkdir(artifacts, { recursive: true }) +const work = await mkdtemp(join(tmpdir(), 'fastread-ui-wire-')) +const ticketCalls = [], results = [], errors = [] +const research = createServer(async (request, response) => { + let raw = ''; for await (const chunk of request) raw += chunk + const body = JSON.parse(raw || '{}') + assert.equal(request.url, '/api/sso/consume') + assert.equal(body.audience, 'fast-read') + ticketCalls.push(body.ticket) + response.setHeader('Content-Type', 'application/json') + if (body.ticket === 'expired-ticket') { response.writeHead(401).end(JSON.stringify({ error: 'Expired ticket' })); return } + response.end(JSON.stringify({ keyId: body.ticket, person: 'News reader' })) +}) +await new Promise(resolve => research.listen(0, '127.0.0.1', resolve)) +const listener = createServer() +await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve)) +const port = listener.address().port +await new Promise(resolve => listener.close(resolve)) +const backend = `http://127.0.0.1:${port}` +const receiver = spawn(python, ['scripts/fastnews-receiver.py', '--root', work, '--port', String(port), '--research', `http://127.0.0.1:${research.address().port}`], { env: { ...process.env, PYTHONUTF8: '1' }, stdio: ['ignore', 'pipe', 'pipe'] }) +let serverLog = '' +for (const stream of [receiver.stdout, receiver.stderr]) stream.on('data', bytes => { serverLog += bytes }) +let server, browser +try { + await expect.poll(async () => { try { return (await fetch(`${backend}/api/health`)).ok } catch { return false } }, { timeout: 20000, message: 'Read fixture must start: ' + serverLog }).toBe(true) + server = await preview({ configFile: false, plugins: [{ name: 'subpath-deployment', configurePreviewServer(server) { + server.middlewares.use((request, _response, next) => { if (request.url.startsWith('/read/')) request.url = request.url.slice('/read'.length); next() }) + } }], preview: { host: '127.0.0.1', port: 0, proxy: { '/api': { target: backend, changeOrigin: false } } } }) + const origin = `http://127.0.0.1:${server.httpServer.address().port}` + browser = await chromium.launch({ headless: true }) + function handoff(base, ticket = '', paper = contract.handoff) { + const result = spawnSync(python, ['-c', 'import json,os,runpy,sys; sys.path.insert(0,os.path.dirname(sys.argv[1])); os.environ["FASTREAD_URL"]=sys.argv[2]; app=runpy.run_path(sys.argv[1],run_name="browser_sender"); status,payload=app["build_fastread_handoff"](json.loads(sys.stdin.read()),ticket=sys.argv[3]); assert status==200; print(json.dumps(payload))', news, base.replace(/\/$/, ''), ticket], { env: { ...process.env, PYTHONUTF8: '1' }, input: JSON.stringify(paper), encoding: 'utf8' }) + assert.equal(result.status, 0, result.stderr) + return JSON.parse(result.stdout).redirect + } + async function login(context, base) { + const init = await context.request.get(base + 'api/auth/session-init') + const result = await context.request.post(base + 'api/auth/login', { headers: { 'X-CSRF-Token': (await init.json()).csrf }, data: { email: 'reader@example.test', password: 'fixture-password-1234' } }) + assert.equal(result.status(), 200) + } + async function session(prefix) { + const base = origin + prefix + const context = await browser.newContext({ serviceWorkers: 'block', viewport: { width: 1440, height: 1000 } }) + const page = await context.newPage(), posts = [], receipts = [] + page.on('pageerror', error => errors.push(error.message)) + page.on('request', request => { if (new URL(request.url()).pathname === prefix + 'api/integrations/fastnews/intake') posts.push(request.postDataJSON()) }) + page.on('response', async response => { if (new URL(response.url()).pathname === prefix + 'api/integrations/fastnews/intake' && response.ok()) receipts.push(await response.json()) }) + const saved = page.getByRole('status').filter({ hasText: '已收入' }) + return { base, context, page, posts, receipts, saved } + } + for (const prefix of ['/', '/read/']) { + for (const ticket of ['', `ticket-${prefix === '/' ? 'root' : 'subpath'}`]) { + const { base, context, page, posts, receipts, saved } = await session(prefix) + if (!ticket) await login(context, base) + await page.goto(handoff(base, ticket)) + await expect(saved).toContainText(contract.intake.title) + assert.deepEqual(posts, [contract.intake]) + await expect.poll(() => receipts.length).toBe(1) + const receipt = receipts[0] + if (ticket) for (const [key, value] of Object.entries(contract.first_receipt)) assert.equal(receipt[key], value) + const stored = await (await context.request.get(base + 'api/papers/' + receipt.id)).json() + assert.equal(stored.title, contract.intake.title) + assert.equal(stored.metadata.source, 'fastnews') + assert.equal(stored.metadata.url, contract.intake.url) + assert.equal((await (await context.request.get(base + 'api/jobs')).json()).items.length, 0, 'metadata intake must not start a PDF/model job') + assert.ok(!page.url().includes('sso=') && !page.url().includes('#fastnews=')) + assert.equal(await page.evaluate(() => Object.keys(sessionStorage).some(key => key.startsWith('fastread:fastnews:'))), false) + if (ticket) assert.equal(ticketCalls.filter(value => value === ticket).length, 1) + await page.reload() + await expect(page.getByRole('button', { name: '模型设置', exact: true })).toBeVisible() + assert.equal(posts.length, 1, 'successful handoff must not repeat after refresh') + await page.screenshot({ path: `${artifacts}/${prefix === '/' ? 'root' : 'subpath'}-${ticket ? 'ticket' : 'session'}.png` }) + results.push({ prefix, scenario: ticket ? 'actual News fragment + ticket + real persisted receipt' : 'actual News fragment + existing session', status: 'passed' }) + await context.close() + } + { + const { base, context, page, posts, receipts, saved } = await session(prefix) + await login(context, base) + await page.goto(handoff(base)) + await expect(saved).toContainText(contract.intake.title) + await expect.poll(() => receipts.length).toBe(1) + const id = receipts[0].id + await page.goto(handoff(base)) + await expect(saved).toContainText(contract.intake.title) + await expect.poll(() => receipts.length).toBe(2) + assert.equal(receipts[1].id, id) + for (const [key, value] of Object.entries(contract.repeat_receipt)) assert.equal(receipts[1][key], value) + assert.deepEqual(posts, [contract.intake, contract.intake]) + results.push({ prefix, scenario: 'explicit repeat handoff follows receiver deduplication receipt', status: 'passed' }) + await context.close() + } + { + const { base, context, page, posts, saved } = await session(prefix) + const ticket = `held-ticket-${prefix === '/' ? 'root' : 'subpath'}` + let started = false, release + const gate = new Promise(resolve => { release = resolve }) + await page.route('**/api/auth/research', async route => { started = true; await gate; await route.continue() }) + try { + await page.goto(handoff(base, ticket)) + await expect.poll(() => started).toBe(true) + assert.equal(new URL(page.url()).searchParams.has('sso'), false, 'remove the ticket before the exchange completes') + assert.equal(await page.evaluate(ticket => JSON.stringify(Object.values(sessionStorage)).includes(ticket), ticket), false) + assert.equal(posts.length, 0) + await expect(saved).toHaveCount(0) + } finally { release() } + await expect(saved).toContainText(contract.intake.title) + assert.equal(ticketCalls.filter(value => value === ticket).length, 1) + results.push({ prefix, scenario: 'ticket removed promptly, never persisted, consumed once before intake', status: 'passed' }) + await context.close() + } + { + const { base, context, page, posts, saved } = await session(prefix) + await login(context, base) + let release + const gate = new Promise(resolve => { release = resolve }) + await page.route('**/api/integrations/fastnews/intake', async route => { await gate; await route.continue() }) + try { + await page.goto(handoff(base)) + await expect.poll(() => posts.length).toBe(1) + await expect(saved).toHaveCount(0) + } finally { release() } + await expect(saved).toContainText(contract.intake.title) + results.push({ prefix, scenario: 'no success before the actual POST receipt', status: 'passed' }) + await context.close() + } + { + const { base, context, page, posts, saved } = await session(prefix) + await login(context, base) + const title = '字面 + %2F # & 科学' + await page.goto(handoff(base, '', { ...contract.handoff, title })) + await expect(saved).toContainText(title) + assert.deepEqual(posts, [{ ...contract.intake, title }], 'decode percent-encoded JSON once, retaining literal plus/percent characters') + results.push({ prefix, scenario: 'actual sender Unicode/plus/percent fragment decoded exactly once', status: 'passed' }) + await context.close() + } + { + const { base, context, page, posts, saved } = await session(prefix) + await page.goto(handoff(base)) + await expect(page.getByRole('heading', { name: '登录资料库' })).toBeVisible() + await expect(saved).toHaveCount(0) + assert.equal(posts.length, 0) + // Navigate away from the original fragment; the pending payload must survive local login. + await page.goto(base) + await page.getByLabel('邮箱', { exact: true }).fill('reader@example.test') + await page.getByLabel('密码', { exact: true }).fill('fixture-password-1234') + await page.getByRole('button', { name: '确认', exact: true }).click() + await expect(saved).toContainText(contract.intake.title) + assert.deepEqual(posts, [contract.intake]) + results.push({ prefix, scenario: 'anonymous arrival, persisted pending, actual local login and intake', status: 'passed' }) + await context.close() + } + for (const failure of ['401', '403', '422', 'network', 'receipt']) { + const { base, context, page, posts, saved } = await session(prefix) + await login(context, base) + let failing = true + await page.route('**/api/integrations/fastnews/intake', async route => { + if (!failing) return route.continue() + const headers = { ...route.request().headers() } + if (failure === 'network') return route.abort('failed') + if (failure === 'receipt') return route.fulfill({ json: { id: 'not-a-receipt' } }) + if (failure === '401') headers.cookie = '' + if (failure === '403') headers['x-csrf-token'] = 'wrong' + const response = await route.fetch({ headers, ...(failure === '422' ? { postData: JSON.stringify({ ...contract.intake, title: 'x'.repeat(301) }) } : {}) }) + assert.equal(response.status(), Number(failure), 'exercise the actual receiver rejection') + return route.fulfill({ response }) + }) + await page.goto(handoff(base)) + const retry = page.getByRole('button', { name: '重试收入', exact: true }) + await expect(retry).toBeVisible() + await expect(saved).toHaveCount(0) + assert.ok(page.url().includes('#fastnews=')) + await page.reload() + await expect(retry).toBeVisible() + await expect(saved).toHaveCount(0) + failing = false + await retry.click() + await expect(saved).toContainText(contract.intake.title) + assert.deepEqual(posts.at(-1), contract.intake) + results.push({ prefix, scenario: `retained payload and explicit retry after ${failure}`, status: 'passed' }) + await context.close() + } + { + const { base, context, page, posts, saved } = await session(prefix) + await login(context, base) + const title = ' ' + contract.intake.title.replaceAll(' ', ' ') + ' ' + const query = new URLSearchParams({ source: 'fastnews', ...contract.intake, title }) + await page.goto(base + '?' + query) + await expect(saved).toContainText(contract.intake.title) + assert.deepEqual(posts, [{ ...contract.intake, title }]) + results.push({ prefix, scenario: 'existing explicit query intake, canonical external_id, receiver-normalized title', status: 'passed' }) + await context.close() + } + { + const { base, context, page, posts, saved } = await session(prefix) + await login(context, base) + await page.goto(handoff(base, 'expired-ticket')) + await expect(page.getByRole('heading', { name: '登录资料库' })).toBeVisible() + assert.equal(posts.length, 0) + assert.equal(new URL(page.url()).searchParams.has('sso'), false) + await expect(saved).toHaveCount(0) + await page.reload() + await expect(page.getByRole('heading', { name: '登录资料库' })).toBeVisible() + assert.equal(posts.length, 0, 'reload after a failed ticket must not use the previous account session') + await page.getByLabel('邮箱', { exact: true }).fill('reader@example.test') + await page.getByLabel('密码', { exact: true }).fill('fixture-password-1234') + await page.getByRole('button', { name: '确认', exact: true }).click() + await expect(saved).toContainText(contract.intake.title) + results.push({ prefix, scenario: 'expired ticket cleaned; no intake until local authentication', status: 'passed' }) + await context.close() + } + { + const { base, context, page, posts, saved } = await session(prefix) + await login(context, base) + await page.goto(base + '?' + new URLSearchParams({ source: 'fastnews', ...contract.intake }) + '#fastnews=%invalid') + await expect(page.getByRole('alert').filter({ hasText: 'FastNews' })).toBeVisible() + await expect(saved).toHaveCount(0) + assert.equal(posts.length, 0, 'malformed fragment must not fall back to the query shape') + results.push({ prefix, scenario: 'malformed fragment fails explicitly without query fallback', status: 'passed' }) + await context.close() + } + } + assert.deepEqual(errors, []) + await writeFile(`${artifacts}/results.json`, JSON.stringify({ status: 'passed', sender: 'News serve.py build_fastread_handoff', receiver: 'Read create_web_app + isolated SQLite + real cookie/CSRF', scenarios: results }, null, 2)) + console.log(`PASS ${results.length} actual News → browser → Read wire scenarios`) +} finally { + await browser?.close() + if (server) await new Promise(resolve => server.httpServer.close(resolve)) + receiver.kill() + await new Promise(resolve => research.close(resolve)) +} diff --git a/fastread-frontend/scripts/fastnews-receiver.py b/fastread-frontend/scripts/fastnews-receiver.py new file mode 100644 index 0000000..41f9b55 --- /dev/null +++ b/fastread-frontend/scripts/fastnews-receiver.py @@ -0,0 +1,23 @@ +"""Browser fixture: run the unchanged Read HTTP application with an isolated store.""" +import argparse +import os +import sys +from pathlib import Path + +parser = argparse.ArgumentParser() +parser.add_argument('--root', required=True) +parser.add_argument('--port', required=True, type=int) +parser.add_argument('--research', required=True) +args = parser.parse_args() +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'backend')) +os.environ['FASTREAD_COOKIE_SECURE'] = 'false' +os.environ['FASTRESEARCH_API_URL'] = args.research +os.environ['CHAT_VECTOR_INDEX_ENABLED'] = 'false' + +import uvicorn +from app.web.api import create_web_app +from app.web.auth import create_user + +app = create_web_app(args.root) +create_user(app.state.store, 'reader@example.test', 'fixture-password-1234') +uvicorn.run(app, host='127.0.0.1', port=args.port, log_level='warning') diff --git a/fastread-frontend/scripts/fastui-browser.mjs b/fastread-frontend/scripts/fastui-browser.mjs new file mode 100644 index 0000000..f0d9496 --- /dev/null +++ b/fastread-frontend/scripts/fastui-browser.mjs @@ -0,0 +1,95 @@ +import { chromium, expect } from '@playwright/test' +import { preview } from 'vite' +import assert from 'node:assert/strict' +import { mkdir, writeFile } from 'node:fs/promises' + +// Run after build. Only the HTTP service is substituted; the production UI stays real. +const artifacts = 'test-results/fastui' +await mkdir(artifacts, { recursive: true }) +const server = await preview({ configFile: false, preview: { host: '127.0.0.1', port: 0 } }) +const base = `http://127.0.0.1:${server.httpServer.address().port}` +const browser = await chromium.launch({ headless: true }) +const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, serviceWorkers: 'block', colorScheme: 'light' }) +const writes = [], errors = [], scenarios = [] +let providers = [{ id: 'provider-1', name: 'Workspace model', models: ['model-a', 'model-b'] }] +await context.route('**/api/**', async route => { + const request = route.request(), path = new URL(request.url()).pathname + if (request.method() === 'POST') writes.push({ path, body: request.postDataJSON() }) + let value = { items: [] }, status = 200 + if (path === '/api/auth/me') value = { id: 'reader', display_name: 'Reader', role: 'owner', workspace_id: 'workspace', csrf: 'fixture-csrf', workspace: { name: 'Workspace', daily_limit: 24 } } + else if (path === '/api/providers') { + if (request.method() === 'POST') providers = [...providers, { id: 'provider-2', ...request.postDataJSON() }] + value = { items: providers } + } else if (path === '/api/auth/fastcas/status') value = { enabled: true, link: { id: 'link', state: 'prepared', subject: 'reader', version: 1 } } + else if (path === '/api/auth/local-credential') { status = 422; value = { detail: '原密码未通过校验' } } + else if (path === '/api/papers') value = { items: [], total: 0 } + await route.fulfill({ status, json: value }) +}) +try { + const page = await context.newPage() + page.on('pageerror', error => errors.push(error.message)) + await page.goto(base) + await page.getByRole('button', { name: '模型设置', exact: true }).click() + await page.getByRole('button', { name: 'Reader', exact: true }).click() + const model = page.getByRole('region', { name: '模型设置' }) + const account = page.getByRole('region', { name: '账号设置' }) + const name = model.getByLabel('名称', { exact: true }) + await name.fill('Research endpoint draft') + await account.getByText('设置本地登录邮箱和密码', { exact: true }).click() + await account.getByLabel('登录邮箱', { exact: true }).fill('reader@example.test') + await account.getByLabel('新密码', { exact: true }).fill('long-password-draft') + await name.focus() + const peer = await context.newPage() + await peer.goto(base) + await peer.evaluate(() => localStorage.setItem('fastread.theme', 'dark')) + await expect(page.locator('html')).toHaveAttribute('data-fastui-theme', 'dark') + await expect(name).toBeFocused() + await expect(name).toHaveValue('Research endpoint draft') + await expect(account.getByLabel('新密码', { exact: true })).toHaveValue('long-password-draft') + await expect(name).toHaveClass(/fastui-input/) + assert.equal(await name.evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(23, 30, 39)') + scenarios.push('cross-tab theme preserves both forms and focused input') + await model.getByLabel('设置页颜色模式').selectOption('system') + await page.emulateMedia({ colorScheme: 'light' }) + await expect(page.locator('html')).toHaveAttribute('data-fastui-theme', 'light') + await page.emulateMedia({ colorScheme: 'dark' }) + await expect(page.locator('html')).toHaveAttribute('data-fastui-theme', 'dark') + scenarios.push('system follows browser preference without remount') + const picker = model.getByLabel('当前使用的模型') + await picker.selectOption('provider-1|model-b') + await expect(picker).toHaveValue('provider-1|model-b') + await name.focus(); await page.keyboard.press('Tab') + await expect(model.getByLabel('API 地址', { exact: true })).toBeFocused() + const save = model.getByRole('button', { name: '保存供应商' }) + await save.click() + assert.equal(writes.length, 0, 'required fields must block submission') + await model.getByLabel('API 地址', { exact: true }).fill('https://model.example.test/v1') + await model.getByLabel('API 密钥', { exact: true }).fill('fixture-key') + await save.click() + await expect(page.getByRole('status').filter({ hasText: '供应商已保存' })).toBeVisible() + assert.deepEqual(writes[0], { path: '/api/providers', body: { name: 'Research endpoint draft', base_url: 'https://model.example.test/v1', api_key: 'fixture-key', models: ['qwen3.8-27b', 'glm-5.2'] } }) + await expect(name).toHaveValue('') + await expect(picker).toHaveValue('provider-1|model-b') + scenarios.push('native select, Tab order, validation, FormData submit and reset') + await account.getByRole('button', { name: '保存本地登录方式' }).click() + await expect(account.getByRole('alert')).toHaveText('原密码未通过校验') + await expect(account.getByLabel('新密码', { exact: true })).toHaveValue('') + await expect(account.getByLabel('登录邮箱', { exact: true })).toHaveValue('reader@example.test') + scenarios.push('account failure feedback and existing credential cleanup') + for (const width of [1440, 1024, 390]) for (const mode of ['light', 'dark']) { + await page.setViewportSize({ width, height: 1000 }) + await model.getByLabel('设置页颜色模式').selectOption(mode) + for (const surface of [model, account]) { + assert.ok(await surface.evaluate(el => el.scrollWidth <= el.clientWidth), 'settings surface overflows') + await expect(surface.locator('.fastui-input').first()).toHaveCSS('background-color', mode === 'dark' ? 'rgb(23, 30, 39)' : 'rgb(255, 255, 255)') + } + await page.evaluate(() => window.scrollTo(0, 0)) + await page.screenshot({ path: `${artifacts}/${mode}-${width}.png`, fullPage: true }) + } + assert.deepEqual(errors, []) + await writeFile(`${artifacts}/results.json`, JSON.stringify({ status: 'passed', scenarios, viewports: [1440, 1024, 390], modes: ['light', 'dark'] }, null, 2)) + console.log('PASS FastRead FastUI production settings/account browser scenarios') +} finally { + await browser.close() + await new Promise(resolve => server.httpServer.close(resolve)) +} diff --git a/fastread-frontend/src/main.tsx b/fastread-frontend/src/main.tsx index 883dbbe..c3d14cd 100644 --- a/fastread-frontend/src/main.tsx +++ b/fastread-frontend/src/main.tsx @@ -1,14 +1,16 @@ import { start } from './web/sso-bootstrap' +import '@fastrd/ui-react/styles.css' +import './web/theme' // Fetch the UI in parallel with authentication, without making authentication // wait for React/Markdown to download and execute first. const app = Promise.all([import('./web/App'), import('react'), import('react-dom/client')]) void app.catch(() => {}) -void start(async initialUser => { +void start(async (initialUser, initialHandoff) => { let timer: ReturnType | undefined const deadline = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('UI load timeout')), 15000) }) const [{ default: App }, { createElement, StrictMode }, { createRoot }] = await Promise.race([app, deadline]).finally(() => clearTimeout(timer)) - createRoot(document.getElementById('root')!).render(createElement(StrictMode, null, createElement(App, { initialUser }))) + createRoot(document.getElementById('root')!).render(createElement(StrictMode, null, createElement(App, { initialUser, initialHandoff }))) }).catch(() => { const root = document.getElementById('root')! root.replaceChildren() diff --git a/fastread-frontend/src/web/App.tsx b/fastread-frontend/src/web/App.tsx index 63274ca..4e0812c 100644 --- a/fastread-frontend/src/web/App.tsx +++ b/fastread-frontend/src/web/App.tsx @@ -12,6 +12,10 @@ import PdfReader from './PdfReader' import Neighbors from './Neighbors' import NotebookDesk from './NotebookDesk' import { PanelLeft } from 'lucide-react' +import { Alert, Button, Select, TextField } from '@fastrd/ui-react' +import { submitFastNews, type FastNewsHandoff } from './sso-bootstrap' +import { colorMode } from './theme' +import './settings.css' type Row = Record let csrf = '' @@ -38,7 +42,20 @@ async function request(path: string, method = 'GET', body?: unknown, key?: strin return value } -export default function WebApp({ initialUser }: { initialUser?: Row | null }) { +function FastNewsNotice({ handoff, onRetry }: { handoff: FastNewsHandoff | null; onRetry?: () => void }) { + if (!handoff) return null + if (handoff.status === 'invalid') return {handoff.error} + if (handoff.status === 'received') return 来自 FastNews,已收入「{handoff.receipt.title}」。{handoff.receipt.awaiting_file ? '请自行上传 PDF。' : '原有文档已保留。'} + if (handoff.status === 'login-required') return FastNews 登录票据未完成验证,「{handoff.body.title}」尚未收入。请重新登录后继续。 + return +

来自 FastNews,「{handoff.body.title}」尚未收入。{handoff.status === 'failed' ? handoff.error : onRetry ? '正在收入论文信息…' : '请登录后继续。'}

+ {onRetry && } +
+} + +export default function WebApp({ initialUser, initialHandoff = null }: { initialUser?: Row | null; initialHandoff?: FastNewsHandoff | null }) { + const [handoff, setHandoff] = useState(initialHandoff) + const handoffBody = handoff && 'body' in handoff ? handoff.body : null const [arrival] = useState(() => new URLSearchParams(window.location.search)) const [deskTarget, setDeskTarget] = useState(arrival.get('book') ? { book_id: arrival.get('book') } : null) const [user, setUser] = useState(() => { if (initialUser) csrf = initialUser.csrf; return initialUser || null }) @@ -67,14 +84,16 @@ export default function WebApp({ initialUser }: { initialUser?: Row | null }) { const [searchResults, setSearchResults] = useState(null) const [searching, setSearching] = useState(false) const [settings, setSettings] = useState(false) + const [themeMode, setThemeMode] = useState(colorMode.mode) + useEffect(() => colorMode.subscribe(() => setThemeMode(colorMode.mode)), []) const [accountSettings, setAccountSettings] = useState(false) const [topic, setTopic] = useState(null) const [books, setBooks] = useState([]) const [importBook, setImportBook] = useState('') const [tagFilter, setTagFilter] = useState('') const [neighborTag, setNeighborTag] = useState('') - const [importUrl, setImportUrl] = useState(arrival.get('url') || '') - const [sidebarOpen, setSidebarOpen] = useState(() => arrival.has('url') || arrival.get('source') === 'fastnews' || arrival.has('pending') || localStorage.getItem('fastread:sidebar') === 'open') + const [importUrl, setImportUrl] = useState(handoffBody?.url || arrival.get('url') || '') + const [sidebarOpen, setSidebarOpen] = useState(() => !!initialHandoff || arrival.has('url') || arrival.get('source') === 'fastnews' || arrival.has('pending') || localStorage.getItem('fastread:sidebar') === 'open') useEffect(() => { localStorage.setItem('fastread:sidebar', sidebarOpen ? 'open' : 'closed') }, [sidebarOpen]) useEffect(() => { const close = (e: KeyboardEvent) => { if (e.key === 'Escape') setSidebarOpen(false) }; window.addEventListener('keydown', close); return () => window.removeEventListener('keydown', close) }, []) @@ -87,6 +106,16 @@ export default function WebApp({ initialUser }: { initialUser?: Row | null }) { setPapers(data.items); setTotal(data.total) setBooks((await api('/knowledge-bases')).items) } + async function retryFastNews() { + if (!handoffBody || handoff?.status === 'pending') return + const body = handoffBody + setHandoff({ status: 'pending', body }) + try { + const receipt = await submitFastNews(body, csrf) + setHandoff({ status: 'received', body, receipt }) + void action(loadLibrary) + } catch (error) { setHandoff({ status: 'failed', body, error: (error as Error).message }) } + } async function loadProviders() { const data = await api('/providers'); setProviders(data.items) if (data.items.length && !providerId) { @@ -160,19 +189,20 @@ export default function WebApp({ initialUser }: { initialUser?: Row | null }) { return job } if (loading) return
正在连接资料库…
- if (!user) return + if (!user) return <> return (
{safeIdentityLabel(user.display_name || user.workspace.name, 'FastRead 用户')}
{!user.public && }
{error &&
{error}
} {notice &&
{notice}
} + void retryFastNews()} /> {accountSettings && } - {settings &&

工作区模型

供应商密钥仅保存在服务器,其他设备登录后可继续使用。当前 24 小时任务上限:{user.workspace.daily_limit}。

{providers.map(p =>

{p.name} · {p.models.join(' / ')}

)} + {settings &&

工作区模型

供应商密钥仅保存在服务器,其他设备登录后可继续使用。当前 24 小时任务上限:{user.workspace.daily_limit}。

{providers.map(p =>

{p.name} · {p.models.join(' / ')}

)} {user.role === 'owner' &&
{ e.preventDefault(); const f = e.currentTarget; const data = Object.fromEntries(new FormData(f)); void action(async () => { await api('/providers', 'POST', { ...data, models: String(data.models).split(',').map(v => v.trim()) }); f.reset(); await loadProviders(); setNotice('供应商已保存') }) }}> - +
}
}
{sidebarOpen &&
{ e.preventDefault(); const query = String(new FormData(e.currentTarget).get('query')); setSearching(true); void action(async () => setSearchResults(await api('/search', 'POST', { query }))).finally(() => setSearching(false)) }}>
{metadataModel && 第一页文本将交给所选模型,计入工作区模型任务额度。} -
导入论文{arrival.get("source") === "fastnews" &&

{arrival.get("title") ? `来自 FastNews,已收入「${arrival.get("title")}」。请自行上传 PDF。` : "来自 FastNews,请自行上传 PDF。"}

}
{ e.preventDefault(); const url = String(new FormData(e.currentTarget).get('url')); void action(() => submitJob('/imports/url', { url, knowledge_id: importBook, source: arrival.get('source') || '', external_id: arrival.get('external_id') || '' })) }}>
+
导入论文
{ e.preventDefault(); const url = String(new FormData(e.currentTarget).get('url')); void action(() => submitJob('/imports/url', { url, knowledge_id: importBook, source: handoffBody ? 'fastnews' : arrival.get('source') || '', external_id: handoffBody?.external_id || arrival.get('external_id') || '' })) }}>
{offset + 1}–{Math.min(offset + 20, total)}
{deskTarget ? { setDeskTarget(null); setSelected(null); window.history.replaceState(null, '', APP_BASE) }} onExternal={(id: string) => { setDeskTarget({ paper_id: id }); window.history.replaceState(null, '', APP_BASE + '?paper=' + encodeURIComponent(id)) }} /> : searchResults ?

搜索结果

公开来源:{Object.entries(searchResults.sources || {}).map(([name, status]) => `${name} ${(status as Row).available ? '可用' : (status as Row).reason || '暂不可用'}`).join(' · ')}

{searchResults.papers.map((p: Row, i: number) =>

{p.title}

{p.authors?.join(', ')} · {p.year || '年份待核实'}

{(p.discovery_sources || []).join(' / ')}{p.doi ? ` · DOI: ${p.doi}` : ''}{p.identity_warning &&

{p.identity_warning}

}

{p.abstract?.slice(0, 500)}

{(p.pdf_url || p.url || p.source_url) && 查看来源}
)}
: topic ?

{topic.question}

{topic.scope}

专题证据

{topic.evidence.map((e: Row) =>
第 {e.page} 页 · {e.quote}

{e.note}

{e.location_status === 'located' ? '逐字引文已在迁移原文定位' : '原文定位待复核'}
)}
: !selected ? { setDeskTarget({ book_id: id }); if(window.innerWidth > 900) setSidebarOpen(true); window.history.replaceState(null, '', APP_BASE + '?book=' + encodeURIComponent(id)) }} api={api} action={action} openPaper={openPaper} submitJob={submitJob} providerId={providerId} model={model} revision={jobs.map(j => `${j.id}:${j.state}`).join(',')} initialTag={tagFilter} /> : <> diff --git a/fastread-frontend/src/web/FastCASAccount.tsx b/fastread-frontend/src/web/FastCASAccount.tsx index 026c5ef..522bc74 100644 --- a/fastread-frontend/src/web/FastCASAccount.tsx +++ b/fastread-frontend/src/web/FastCASAccount.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { Alert, Button, Loading, TextField } from '@fastrd/ui-react' type Status = { enabled: boolean; link: null | { id: string; subject: string; state: string; version: number } } export default function FastCASAccount({ api }: { api: (path: string, method?: string, body?: unknown) => Promise }) { @@ -29,24 +30,24 @@ export default function FastCASAccount({ api }: { api: (path: string, method?: s } catch (e) { setError((e as Error).message) } finally { setBusy(false); setNewPassword(''); setCurrentPassword('') } } - return

FastCAS 账号认证

- {error &&

{error}

} - {!status ?

正在读取账号状态…

: !status.enabled ?

此实例未启用 FastCAS,本地登录照常可用。

: <> + return

FastCAS 账号认证

+ {error && {error}} + {!status ? : !status.enabled ?

此实例未启用 FastCAS,本地登录照常可用。

: <>

{!status.link ? '尚未认证' : status.link.state === 'active' ? '已通过 FastCAS 认证' : '认证待完成'}

认证关联当前账号,资料库与权限保持不变。解除后仍可使用本地密码登录。

- - {!status.link ? : <> - {status.link.state === 'prepared' && } - + + {!status.link ? : <> + {status.link.state === 'prepared' && } + } }
设置本地登录邮箱和密码

已有密码时请验证原密码。Research 账号请先从原入口重新登录,并在五分钟内设置;账号和资料库保持不变。

{ e.preventDefault(); void saveCredential() }}> - - - - + + + +
diff --git a/fastread-frontend/src/web/LocalLogin.tsx b/fastread-frontend/src/web/LocalLogin.tsx index d010e6a..ae6bcde 100644 --- a/fastread-frontend/src/web/LocalLogin.tsx +++ b/fastread-frontend/src/web/LocalLogin.tsx @@ -1,5 +1,6 @@ import { API_BASE } from './urls' import { useEffect, useState, type FormEvent } from 'react' +import { resumeFastNewsAfterLogin } from './sso-bootstrap' export default function LocalLogin() { const [mode, setMode] = useState<'login' | 'register' | 'recover'>('login') @@ -23,7 +24,7 @@ export default function LocalLogin() { }) const result = await response.json() if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : '请检查输入内容') - if (mode === 'login') window.location.reload() + if (mode === 'login') { resumeFastNewsAfterLogin(); window.location.reload() } else { setMode('login'); setPassword(''); setToken(''); setMessage('密码已设置,请使用邮箱和密码登录。') } } catch (error) { setMessage((error as Error).message) } finally { setBusy(false) } diff --git a/fastread-frontend/src/web/settings.css b/fastread-frontend/src/web/settings.css new file mode 100644 index 0000000..a8f17d9 --- /dev/null +++ b/fastread-frontend/src/web/settings.css @@ -0,0 +1,22 @@ +/* These two surfaces replace the old .settings skin; controls use ui-react styles. */ +.fastread-settings { + margin: 16px clamp(16px, 3vw, 38px); + padding: 24px; + border: 1px solid var(--fastui-border); + border-radius: var(--fastui-radius-panel); + background: var(--fastui-surface); + color: var(--fastui-text); + color-scheme: var(--fastui-color-scheme); + font-family: var(--fastui-font); + display: grid; + gap: 16px; +} +.fastread-settings h2, .fastread-settings p { margin: 0; } +.fastread-settings label { color: var(--fastui-secondary); } +.fastread-settings form { max-width: 640px; } +.fastread-settings :is(.fastui-input, .fastui-select) { width: 100%; } +.fastread-settings > .fastui-button, .fastread-settings form > .fastui-button { justify-self: start; align-self: start; } +.fastread-settings .fastui-button:hover { filter: none; } +.fastread-settings summary { cursor: pointer; } +.fastread-settings details > p { margin: 12px 0; } +@media (max-width: 700px) { .fastread-settings { padding: 16px; } } diff --git a/fastread-frontend/src/web/sso-bootstrap.ts b/fastread-frontend/src/web/sso-bootstrap.ts index 13a3ccf..54b3c42 100644 --- a/fastread-frontend/src/web/sso-bootstrap.ts +++ b/fastread-frontend/src/web/sso-bootstrap.ts @@ -1,125 +1,144 @@ -import { API_BASE } from './urls' +import { APP_BASE, API_BASE } from './urls' +type FastNewsIntake = { title: string; authors: string; abstract: string; url: string; venue: string; year: string; external_id: string } +type FastNewsReceipt = { id: string; title: string; awaiting_file: boolean; created: boolean } +type PendingFastNews = { status: 'pending' | 'login-required'; body: FastNewsIntake } +export type FastNewsHandoff = + | PendingFastNews + | { status: 'failed'; body: FastNewsIntake; error: string } + | { status: 'received'; body: FastNewsIntake; receipt: FastNewsReceipt } + | { status: 'invalid'; error: string } -function dropSso(params: URLSearchParams) { - params.delete("sso"); - const query = params.toString(); - history.replaceState(null, "", `${location.pathname}${query ? `?${query}` : ""}${location.hash}`); +const pendingKey = `fastread:fastnews:${APP_BASE}` +const intakeFields = ['title', 'authors', 'abstract', 'url', 'venue', 'year', 'external_id'] as const + +function intakeBody(value: unknown): FastNewsIntake { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('FastNews 论文信息格式无效,请从原入口重新发送。') + const record = value as Record + const body = Object.fromEntries(intakeFields.map(key => [key, String(record[key] ?? '')])) as FastNewsIntake + if (!body.title.trim()) throw new Error('FastNews 论文缺少标题,请从原入口重新发送。') + return body } -function showTicketError(panel: string) { - const root = document.getElementById("root") || document.body; - root.innerHTML = ` -
-
FastRead
-

Panel 登录已过期

-

请从 FastResearch 重新进入。

-

打开 FastResearch

-
`; +function readPending(): PendingFastNews | null { + let raw: string | null = null + try { raw = sessionStorage.getItem(pendingKey) } catch { return null } + if (!raw) return null + const stored = JSON.parse(raw) + if (stored?.status !== 'pending' && stored?.status !== 'login-required') throw new Error('FastNews 待收入信息无效,请从原入口重新发送。') + return { status: stored.status, body: intakeBody(stored.body) } } -async function readJson(path: string, options?: RequestInit) { - const response = await fetch(path.replace(/^\/api(?=\/)/, API_BASE), { credentials: "same-origin", cache: "no-store", signal: AbortSignal.timeout(10000), ...options }); - const payload = await response.json().catch(() => ({})); - return { response, payload }; +function savePending(pending: PendingFastNews) { + try { sessionStorage.setItem(pendingKey, JSON.stringify(pending)) } catch { /* Keep the carrier in the URL for reload/login. */ } +} + +export function resumeFastNewsAfterLogin() { + // Only a successful local login may release a failed-ticket handoff to the new session. + try { + const pending = readPending() + if (pending?.status === 'login-required') savePending({ status: 'pending', body: pending.body }) + } catch { /* An invalid pending record must not prevent signing in. */ } } -async function panelUrl() { +function pendingHandoff(): FastNewsHandoff | null { try { - const { response, payload } = await readJson("/api/auth/login-info"); - if (response.ok && payload.panel_url) return String(payload.panel_url); - } catch { - /* use fallback below */ + const params = new URLSearchParams(location.search) + const previous = readPending() + let body: FastNewsIntake + if (location.hash.startsWith('#fastnews=')) { + // News' fragment is percent-encoded JSON, not a URLSearchParams value. Decode exactly once. + const carrier = JSON.parse(decodeURIComponent(location.hash.slice('#fastnews='.length))) + if (!carrier || carrier.source !== 'fastnews') throw new Error('FastNews 来源信息无效,请从原入口重新发送。') + body = intakeBody({ ...carrier, external_id: carrier.id }) + } else if (params.get('source') === 'fastnews') { + // Existing documented query entry: fields already use canonical external_id. + body = intakeBody(Object.fromEntries(params)) + } else { + return previous + } + // Persist paper metadata and its login requirement, never a ticket. Keep the URL until receipt. + const pending: PendingFastNews = { status: previous?.status ?? 'pending', body } + savePending(pending) + return pending + } catch (error) { + return { status: 'invalid', error: error instanceof URIError || error instanceof SyntaxError ? 'FastNews 论文信息无法解码,请从原入口重新发送。' : (error as Error).message } } - return ""; } -async function currentCsrf(hint: string) { - if (hint) return String(hint); - const { response, payload } = await readJson("/api/auth/me"); - if (!response.ok) return ""; - return String(payload.csrf || ""); +function dropSso() { + const url = new URL(location.href) + url.searchParams.delete('sso') + history.replaceState(null, '', url.pathname + url.search + url.hash) } -async function intakeFastNews(csrf: string) { - const params = new URLSearchParams(location.search); - if ((params.get("source") || "") !== "fastnews") return; - const title = (params.get("title") || "").trim(); - if (!title) return; - let paperId = ""; - try { - const { response, payload } = await readJson("/api/integrations/fastnews/intake", { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": csrf || "", - }, - body: JSON.stringify({ - title, - authors: (params.get("authors") || "").trim(), - abstract: (params.get("abstract") || "").trim(), - url: (params.get("url") || "").trim(), - venue: (params.get("venue") || "").trim(), - year: (params.get("year") || "").trim(), - external_id: (params.get("external_id") || "").trim(), - }), - }); - if (response.ok && payload && payload.id) paperId = String(payload.id); - } catch { - /* still slim the query so the library can load */ +async function readJson(path: string, options?: RequestInit) { + const response = await fetch(`${API_BASE}${path}`, { credentials: 'same-origin', cache: 'no-store', signal: AbortSignal.timeout(10000), ...options }) + const payload = await response.json().catch(() => ({})) + return { response, payload } +} + +export async function submitFastNews(body: FastNewsIntake, csrf: string): Promise { + const { response, payload } = await readJson('/integrations/fastnews/intake', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, + body: JSON.stringify(body), + }) + if (!response.ok) throw new Error(typeof payload?.detail === 'string' ? payload.detail : `论文信息尚未收入(HTTP ${response.status}),请重试。`) + if (!payload || typeof payload.id !== 'string' || !payload.id || typeof payload.title !== 'string' || typeof payload.awaiting_file !== 'boolean' || typeof payload.created !== 'boolean') { + throw new Error('FastRead 未返回有效收入回执,请重试。') + } + try { sessionStorage.removeItem(pendingKey) } catch { /* The server receipt still confirms persistence. */ } + const url = new URL(location.href) + if (url.hash.startsWith('#fastnews=')) url.hash = '' + if (url.searchParams.get('source') === 'fastnews') { + url.searchParams.delete('source') + for (const key of intakeFields) url.searchParams.delete(key) } - const kept = new URLSearchParams(); - kept.set("source", "fastnews"); - kept.set("title", title); - if (paperId) kept.set("pending", paperId); - history.replaceState(null, "", `${location.pathname}?${kept.toString()}${location.hash}`); + // Existing PDF attachment deep-link consumes pending=paper_id, not a success flag. + url.searchParams.set('pending', payload.id) + history.replaceState(null, '', url.pathname + url.search + url.hash) + return payload } -export async function start(loadApp: (user: Record | null) => Promise) { - const params = new URLSearchParams(location.search); - const ticket = (params.get("sso") || "").trim(); - let csrf = ""; - let user: Record | undefined; +export async function start(loadApp: (user: Record | null, handoff: FastNewsHandoff | null) => Promise) { + let handoff = pendingHandoff() + const ticket = (new URLSearchParams(location.search).get('sso') || '').trim() if (ticket) { + dropSso() try { - const { response, payload } = await readJson("/api/auth/research", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ticket }), - }); - if (response.status >= 500) throw new Error("service"); - if (!response.ok) { showTicketError((await panelUrl()) || "/"); return; } - csrf = String(payload.csrf || ""); - dropSso(params); + const { response } = await readJson('/auth/research', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ticket }), + }) + if (!response.ok) throw new Error('登录票据无效或已过期,请登录后继续收入论文。') + if (handoff?.status === 'login-required') { + handoff = { status: 'pending', body: handoff.body } + savePending(handoff) + } } catch { - throw new Error("Login service unavailable"); - } - } else { - const { response: me, payload } = await readJson("/api/auth/me"); - if (me.status === 401) { - await loadApp(null); - return; - } - if (!me.ok) { - throw new Error('Login service unavailable'); - } - try { - csrf = String(payload.csrf || ""); - user = payload; - } catch { - csrf = ""; + if (handoff && 'body' in handoff) { + handoff = { status: 'login-required', body: handoff.body } + savePending(handoff) + } + // An explicit ticket must not silently fall back to an unrelated existing account. + await loadApp(null, handoff) + return } } - if (!csrf) csrf = await currentCsrf(""); - try { - await intakeFastNews(csrf); - } catch { - /* library still loads */ + if (handoff?.status === 'login-required') { + await loadApp(null, handoff) + return + } + const { response, payload: user } = await readJson('/auth/me') + if (response.status === 401) { + await loadApp(null, handoff) + return } - if (!user) { - const result = await readJson('/api/auth/me'); - if (!result.response.ok) throw new Error('Login verification failed'); - user = result.payload; + if (!response.ok) throw new Error('Login verification failed') + if (handoff?.status === 'pending') { + const body = handoff.body + try { handoff = { status: 'received', body, receipt: await submitFastNews(body, String(user.csrf || '')) } } + catch (error) { handoff = { status: 'failed', body, error: (error as Error).message } } } - await loadApp(user!); + await loadApp(user, handoff) } diff --git a/fastread-frontend/src/web/theme.ts b/fastread-frontend/src/web/theme.ts new file mode 100644 index 0000000..0b9fe2f --- /dev/null +++ b/fastread-frontend/src/web/theme.ts @@ -0,0 +1,4 @@ +import { initColorMode } from '@fastrd/ui-foundation' + +// Settings/account surfaces opt in; PDF and reading surfaces retain their own styles. +export const colorMode = initColorMode({ storageKey: 'fastread.theme' }) diff --git a/requirements-fastcas.txt b/requirements-fastcas.txt index ffdb493..db449e5 100644 --- a/requirements-fastcas.txt +++ b/requirements-fastcas.txt @@ -1,4 +1,4 @@ -# Optional development install, from the FastRead repository root: +# Optional CAS-enabled install, from the FastRead repository root: # python -m pip install -r requirements-fastcas.txt # Normal local/Research login does not import this SDK. --e ../FastCAS/sdk/python +fastcas-sdk[fastapi]==0.1.1 From 098e02920e2b3e4b9891eabd750823b8f10fbd25 Mon Sep 17 00:00:00 2001 From: Hsukqi Lee Date: Wed, 11 Nov 2026 14:43:42 +0800 Subject: [PATCH 3/6] chore: consume verified npm UI and transport releases --- fastread-frontend/package.json | 4 ++-- fastread-frontend/pnpm-lock.yaml | 24 +++++++++++------------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/fastread-frontend/package.json b/fastread-frontend/package.json index afbbb29..0116034 100644 --- a/fastread-frontend/package.json +++ b/fastread-frontend/package.json @@ -16,8 +16,8 @@ "test:fastnews:browser": "node scripts/fastnews-browser.mjs" }, "dependencies": { - "@fastrd/ui-foundation": "https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz", - "@fastrd/ui-react": "https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-react-0.1.0.tgz", + "@fastrd/ui-foundation": "0.1.2", + "@fastrd/ui-react": "0.1.2", "@ant-design/x": "1.6.1", "@hookform/resolvers": "^5.0.1", "@lobehub/icons": "^1.97.1", diff --git a/fastread-frontend/pnpm-lock.yaml b/fastread-frontend/pnpm-lock.yaml index f9f6f6e..0cebcfb 100644 --- a/fastread-frontend/pnpm-lock.yaml +++ b/fastread-frontend/pnpm-lock.yaml @@ -12,11 +12,11 @@ importers: specifier: 1.6.1 version: 1.6.1(antd@5.29.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@fastrd/ui-foundation': - specifier: https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz - version: https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz + specifier: 0.1.2 + version: 0.1.2 '@fastrd/ui-react': - specifier: https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-react-0.1.0.tgz - version: https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-react-0.1.0.tgz(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 0.1.2 + version: 0.1.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@hookform/resolvers': specifier: ^5.0.1 version: 5.2.2(react-hook-form@7.72.0(react@19.2.4)) @@ -688,13 +688,11 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@fastrd/ui-foundation@https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz': - resolution: {integrity: sha512-5O4s9BXXHXWk7GbzPfMYX6HjNi0DKryfWLRPYMZINNb4ew3arjCKbJvxFaSvqcCvhHxmrf8OZDg9GPmfvdnRtw==, tarball: https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz} - version: 0.1.0 + '@fastrd/ui-foundation@0.1.2': + resolution: {integrity: sha512-Jobx7DanQOejVRFfsT5DFXND7rtcvSUzZ5Zui+lY3oAuretz8vwkzqHh1Gd0+ezJubw82WeFsnsXdvbWB4t1YA==} - '@fastrd/ui-react@https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-react-0.1.0.tgz': - resolution: {integrity: sha512-zsnN8Afm889TyhPX0ZQQ4hIn5Bj1EsFbEZ857CQXDgmCIOEfY3369bCFn1VEelR/EWjU5Lpyjj6D1E9lVtQ5uw==, tarball: https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-react-0.1.0.tgz} - version: 0.1.0 + '@fastrd/ui-react@0.1.2': + resolution: {integrity: sha512-/7WrTu/LrsMWcJKQglQBmpe+sXWj0hGUt66mgrZ3f6B/5dZfb03xdrbPSKRRGFmCA8H6O8stmU9oOFMSuW7YHQ==} peerDependencies: react: ^18.3.0 || ^19.0.0 react-dom: ^18.3.0 || ^19.0.0 @@ -5560,11 +5558,11 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@fastrd/ui-foundation@https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz': {} + '@fastrd/ui-foundation@0.1.2': {} - '@fastrd/ui-react@https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-react-0.1.0.tgz(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@fastrd/ui-react@0.1.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@fastrd/ui-foundation': https://github.com/FastR-D/FastUI/releases/download/v0.1.0/fastrd-ui-foundation-0.1.0.tgz + '@fastrd/ui-foundation': 0.1.2 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) From 094531e5272986837a89c40e558ab958ae03ae56 Mon Sep 17 00:00:00 2001 From: GuGuMur <2221533105@qq.com> Date: Wed, 11 Nov 2026 23:25:05 +0800 Subject: [PATCH 4/6] Configure independent preview domains and pin FastDeploy v2.0.2 --- .github/workflows/rollback.yml | 2 +- .github/workflows/tag-ci.yml | 8 ++++---- docs/fastdeploy.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/rollback.yml b/.github/workflows/rollback.yml index 7722087..143861f 100644 --- a/.github/workflows/rollback.yml +++ b/.github/workflows/rollback.yml @@ -14,7 +14,7 @@ permissions: jobs: rollback: if: github.ref == 'refs/heads/main' - uses: FastR-D/FastDeploy/.github/workflows/rollback.yml@cc223a0b83b0ef2389974dd70a4b74148cd1af87 + uses: FastR-D/FastDeploy/.github/workflows/rollback.yml@9a2c51dcd63f47a0e6c8f6bb07bd22ea589abbd2 with: app: fastread expected-release: ${{ inputs.expected-release }} diff --git a/.github/workflows/tag-ci.yml b/.github/workflows/tag-ci.yml index 2dd4069..d03981f 100644 --- a/.github/workflows/tag-ci.yml +++ b/.github/workflows/tag-ci.yml @@ -47,7 +47,7 @@ jobs: uses: actions/checkout@v4 with: repository: FastR-D/FastDeploy - ref: cc223a0b83b0ef2389974dd70a4b74148cd1af87 + ref: 9a2c51dcd63f47a0e6c8f6bb07bd22ea589abbd2 path: .fastdeploy-tools persist-credentials: false - name: Package the tested runtime @@ -62,7 +62,7 @@ jobs: deploy: needs: validate if: startsWith(github.ref, 'refs/tags/v') - uses: FastR-D/FastDeploy/.github/workflows/deploy.yml@cc223a0b83b0ef2389974dd70a4b74148cd1af87 + uses: FastR-D/FastDeploy/.github/workflows/deploy.yml@9a2c51dcd63f47a0e6c8f6bb07bd22ea589abbd2 with: app: fastread artifact-name: release-${{ github.sha }} @@ -87,8 +87,8 @@ jobs: FASTREAD_FASTCAS_ALLOW_LOOPBACK_HTTP=false FASTREAD_FASTCAS_CLIENT_ID=fastread FASTREAD_FASTCAS_ISSUER=https://cas.fastrd.io - FASTREAD_FASTCAS_REDIRECT_URI=https://fastrd.io/read/api/auth/fastcas/callback - FASTREAD_PUBLIC_ORIGIN=https://fastrd.io + FASTREAD_FASTCAS_REDIRECT_URI=https://read.fastrd.io/api/auth/fastcas/callback + FASTREAD_PUBLIC_ORIGIN=https://read.fastrd.io FASTREAD_WEB_ROOT=/home/dev/fastread-web-shared/data FASTRESEARCH_API_URL=http://127.0.0.1:8787 FASTRESEARCH_PANEL_URL=https://fastrd.io diff --git a/docs/fastdeploy.md b/docs/fastdeploy.md index 57ba95b..bbcff16 100644 --- a/docs/fastdeploy.md +++ b/docs/fastdeploy.md @@ -4,7 +4,7 @@ 普通运行参数直接写在该 workflow 的 `runtime-env`,使用字面 `KEY=value`,不要加 shell 引号。敏感值用该文件 `runtime-secrets` 中的同名仓库 Secrets,使用 `toJSON` 保留特殊字符。SSH 五项来自 FastR-D 组织的 `FASTRD_SSH_*` Secrets;请勿把凭证写入 YAML 或构建产物。 -构建 job 上传同一次运行的 `release.tar.gz` 和 `manifest.json`。FastDeploy 和打包器均固定到完整提交 `cc223a0b83b0ef2389974dd70a4b74148cd1af87`,服务器不拉源码或重新安装依赖。代码相对路径由 [FastDeploy 应用配置](https://github.com/FastR-D/FastDeploy/blob/cc223a0b83b0ef2389974dd70a4b74148cd1af87/config/apps.json) 注入,业务数据使用其中记录的原持久化路径。 +构建 job 上传同一次运行的 `release.tar.gz` 和 `manifest.json`。FastDeploy 和打包器均固定到完整提交 `9a2c51dcd63f47a0e6c8f6bb07bd22ea589abbd2`,服务器不拉源码或重新安装依赖。代码相对路径由 [FastDeploy 应用配置](https://github.com/FastR-D/FastDeploy/blob/9a2c51dcd63f47a0e6c8f6bb07bd22ea589abbd2/config/apps.json) 注入,业务数据使用其中记录的原持久化路径。 发布示例(版本号每次必须唯一): From cdced79d4673971bc3e9d72eb8525f3dc6f9d22c Mon Sep 17 00:00:00 2001 From: GuGuMur <2221533105@qq.com> Date: Wed, 11 Nov 2026 23:31:01 +0800 Subject: [PATCH 5/6] Separate server deployment tags from desktop and Docker releases --- .github/workflows/docker-build.yml | 1 + .github/workflows/main.yml | 1 + docs/fastdeploy.md | 6 ++++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index ac3d7b2..3b5e5a7 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -4,6 +4,7 @@ on: push: tags: - 'v*' + - '!v*-deploy.*' workflow_dispatch: env: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6db8b4a..dcb28a1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,6 +4,7 @@ on: push: tags: - 'v*' + - '!v*-deploy.*' workflow_dispatch: jobs: diff --git a/docs/fastdeploy.md b/docs/fastdeploy.md index bbcff16..f81e7d6 100644 --- a/docs/fastdeploy.md +++ b/docs/fastdeploy.md @@ -9,10 +9,12 @@ 发布示例(版本号每次必须唯一): ```bash -git tag v2026.11.11.1 -git push origin v2026.11.11.1 +git tag v2026.11.11-deploy.1 +git push origin v2026.11.11-deploy.1 ``` 生产 Environment 仅允许 `v*` 标签和 main 上的手动回滚。切换后自动检查就绪,失败恢复旧代码和运行配置;数据库不会随代码回滚。需要回滚时,在 main 手动执行 `.github/workflows/rollback.yml`,输入部署日志中的准确当前 release ID;若当前版本已变化则拒绝执行。 适配 main 提交不会重启当前业务。首个生产标签仍需验证登录、集成功能及数据兼容性。完整运行时、共享目录、旧服务接管和检查限制见 [FastDeploy README](https://github.com/FastR-D/FastDeploy#readme)。 + +服务器使用 `v*-deploy.*` 标签,桌面端及 Docker 发布流程排除此类标签,避免服务器部署同时发布客户端或镜像。 From 77c8f7ed73ba903098050d6f2057b586c18bff92 Mon Sep 17 00:00:00 2001 From: Hsukqi Lee Date: Thu, 12 Nov 2026 01:56:10 +0800 Subject: [PATCH 6/6] ci: validate squash titles without rewriting integration history --- .github/workflows/commitlint.yml | 23 +++++++++++++++++++++-- docs/CONTRIBUTING.md | 14 ++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 docs/CONTRIBUTING.md diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 145a6fc..666f0f5 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -2,7 +2,7 @@ name: Commit Lint on: pull_request: - types: [opened, synchronize, reopened, edited] + types: [opened, synchronize, reopened, edited, ready_for_review] push: branches: - develop @@ -23,8 +23,27 @@ jobs: fetch-depth: 0 - name: Run commitlint + if: github.event_name == 'push' uses: wagoid/commitlint-github-action@v6 with: configFile: .commitlintrc.json failOnWarnings: false - helpURL: https://github.com/FastR-D/FastRead/blob/develop/CONTRIBUTING.md#5-提交规范 + helpURL: https://github.com/FastR-D/FastRead/blob/main/docs/CONTRIBUTING.md + + - name: Set up Node + if: github.event_name == 'pull_request' + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install commitlint + if: github.event_name == 'pull_request' + run: npm install --prefix "$RUNNER_TEMP/commitlint" --no-package-lock --ignore-scripts @commitlint/cli@19.8.1 @commitlint/config-conventional@19.8.1 + + - name: Lint squash commit title + if: github.event_name == 'pull_request' + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + export NODE_PATH="$RUNNER_TEMP/commitlint/node_modules" + printf '%s\n' "$PR_TITLE" | "$RUNNER_TEMP/commitlint/node_modules/.bin/commitlint" --config .commitlintrc.json diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..759331a --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,14 @@ +# Contributing + +Use Conventional Commits for changes, for example `fix(auth): restore pending paper after login`. +The accepted types and formatting rules are defined in `.commitlintrc.json`. + +Pull requests are squash-merged using their title as the commit title. CI validates +that title with the same commitlint configuration. This lets a branch retain its +working and upstream integration history while ensuring the resulting main-branch +commit follows the convention. Edit the PR title to correct a failed title check; +do not rewrite published integration history to satisfy the check. + +Direct pushes to `develop` and `master` still validate their individual commit +messages. Before merging a PR, wait for its functional checks and title check to +pass, and use the validated title for the squash commit.