From d42161ec520b2524995de1854241e38726b2a779 Mon Sep 17 00:00:00 2001
From: Funkierry <105119784+Funkierry@users.noreply.github.com>
Date: Mon, 5 Oct 2026 23:35:31 +1100
Subject: [PATCH] Add tracker cursor pages and tested commit-tagged deployment
images
---
.github/workflows/jobscout-deploy.yml | 76 ++-
README.md | 4 +
backend/app/application_tracker/AGENTS.md | 5 +
.../app/application_tracker/email/store.py | 35 +-
backend/app/application_tracker/pagination.py | 77 +++
backend/app/application_tracker/store.py | 5 +
backend/app/gateway/routers/jobscout.py | 9 +-
backend/tests/test_jobscout_deployment.py | 44 ++
backend/tests/test_jobscout_pagination.py | 93 +++
deploy/jobscout/env.example | 6 +
deploy/jobscout/gateway.Dockerfile | 5 +
.../jobscout/gateway.Dockerfile.dockerignore | 5 +
deploy/jobscout/web.Dockerfile | 4 +
deploy/jobscout/web.Dockerfile.dockerignore | 13 +
docker/docker-compose.jobscout-images.yaml | 18 +
docker/nginx/jobscout.conf | 1 +
docs/jobscout/deployment.md | 31 +-
docs/jobscout/reliability.md | 7 +-
jobscout-web/AGENTS.md | 5 +
jobscout-web/README.md | 9 +
jobscout-web/api-client.js | 56 ++
jobscout-web/app.js | 511 ++--------------
jobscout-web/core.js | 565 ++++++++++++++++++
jobscout-web/index.html | 8 +
jobscout-web/style.css | 3 +
jobscout-web/test_async_state.js | 9 +-
jobscout-web/test_concurrency.py | 57 +-
jobscout-web/test_deployment.py | 17 +-
jobscout-web/test_history_scroll.py | 214 ++++++-
jobscout-web/test_link_guard.js | 2 +-
jobscout-web/test_modules.js | 72 +++
jobscout-web/test_pure.js | 1 +
jobscout-web/test_ui.py | 54 +-
jobscout-web/tracker-state.js | 61 ++
scripts/AGENTS.md | 5 +
scripts/jobscout.py | 61 +-
36 files changed, 1622 insertions(+), 526 deletions(-)
create mode 100644 backend/app/application_tracker/pagination.py
create mode 100644 backend/tests/test_jobscout_pagination.py
create mode 100644 deploy/jobscout/gateway.Dockerfile
create mode 100644 deploy/jobscout/gateway.Dockerfile.dockerignore
create mode 100644 deploy/jobscout/web.Dockerfile
create mode 100644 deploy/jobscout/web.Dockerfile.dockerignore
create mode 100644 docker/docker-compose.jobscout-images.yaml
create mode 100644 jobscout-web/api-client.js
create mode 100644 jobscout-web/core.js
create mode 100644 jobscout-web/test_modules.js
create mode 100644 jobscout-web/tracker-state.js
diff --git a/.github/workflows/jobscout-deploy.yml b/.github/workflows/jobscout-deploy.yml
index 8ae7047b..9cb337c7 100644
--- a/.github/workflows/jobscout-deploy.yml
+++ b/.github/workflows/jobscout-deploy.yml
@@ -12,13 +12,15 @@ on:
- 'jobscout-web/**'
- 'deploy/jobscout/**'
- 'docker/**'
+ - 'skills/public/**'
+ - '.dockerignore'
- '.github/workflows/jobscout-deploy.yml'
push:
branches: [main]
paths:
- '.github/workflows/jobscout-deploy.yml'
- 'deploy/jobscout/**'
- - 'docker/docker-compose.jobscout.yaml'
+ - 'docker/docker-compose.jobscout*.yaml'
- 'docker/nginx/jobscout.conf'
- 'backend/Dockerfile'
- 'backend/app/**'
@@ -28,6 +30,7 @@ on:
- 'scripts/jobscout*.py'
- 'jobscout-web/**'
- '.dockerignore'
+ - 'skills/public/**'
permissions:
contents: read
concurrency:
@@ -36,7 +39,10 @@ concurrency:
jobs:
container-smoke:
runs-on: ubuntu-latest
- timeout-minutes: 40
+ timeout-minutes: 60
+ permissions:
+ contents: read
+ packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
@@ -94,9 +100,73 @@ jobs:
assert status['needs_setup'] is False
print('Administrator restored into a fresh data volume')
PY
+ - name: Package the tested application as commit-tagged images
+ env:
+ REPOSITORY: ${{ github.repository }}
+ COMMIT: ${{ github.sha }}
+ run: |
+ python scripts/jobscout.py down
+ prefix="ghcr.io/${REPOSITORY,,}"
+ tag="sha-${COMMIT}"
+ echo "JOBSCOUT_RELEASE_PREFIX=${prefix}" >> "$GITHUB_ENV"
+ echo "JOBSCOUT_RELEASE_TAG=${tag}" >> "$GITHUB_ENV"
+ docker build -f deploy/jobscout/gateway.Dockerfile --label "org.opencontainers.image.source=https://github.com/${REPOSITORY}" --label "org.opencontainers.image.revision=${COMMIT}" -t "${prefix}-gateway:${tag}" .
+ docker build -f deploy/jobscout/web.Dockerfile --label "org.opencontainers.image.source=https://github.com/${REPOSITORY}" --label "org.opencontainers.image.revision=${COMMIT}" -t "${prefix}-web:${tag}" .
+ docker tag jobscout-frontend:latest "${prefix}-capabilities:${tag}"
+ python - <<'PY'
+ import os
+ from pathlib import Path
+ p = Path('.jobscout/.env')
+ p.write_text(p.read_text() + '\nJOBSCOUT_DEPLOY_MODE=images\nJOBSCOUT_IMAGE_PREFIX=ghcr.io/' + os.environ['REPOSITORY'].lower() + '\nJOBSCOUT_IMAGE_TAG=sha-' + os.environ['COMMIT'] + '\nJOBSCOUT_DATA_VOLUME=jobscout-image-data\n')
+ PY
+ - name: Start packaged images and verify no source mounts
+ run: |
+ python scripts/jobscout.py up --no-pull
+ python scripts/jobscout_smoke.py
+ python - <<'PY'
+ import importlib.util, json, subprocess, urllib.request
+ from pathlib import Path
+ spec = importlib.util.spec_from_file_location('deploy', 'scripts/jobscout.py')
+ deploy = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(deploy)
+ directory = Path('.jobscout').resolve()
+ config = json.loads(deploy.compose(directory, ['config', '--format', 'json'], check=True, capture_output=True, text=True).stdout)
+ services = config['services']
+ for service in ('gateway', 'frontend', 'nginx'):
+ assert 'build' not in services[service]
+ assert {v['target'] for v in services['gateway']['volumes']} == {'/config/config.yaml', '/config/extensions_config.json', '/data'}
+ assert {v['target'] for v in services['nginx']['volumes']} == {'/usr/share/nginx/jobscout/runtime-config.js'}
+ deploy.compose(directory, ['exec', '-T', 'gateway', '/app/backend/.venv/bin/python', '-c', "from pathlib import Path; assert list(Path('/app/skills/public').glob('*/SKILL.md'))"], check=True)
+ for name in ('core', 'api-client', 'tracker-state'):
+ response = urllib.request.urlopen('http://127.0.0.1:2026/' + name + '.js')
+ assert response.status == 200 and 'javascript' in response.headers['Content-Type']
+ print('Packaged assets and public skills ready; no application source bind mounts')
+ PY
+ - name: Sign in to GitHub Container Registry
+ if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Publish tested commit images
+ if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
+ run: |
+ for component in gateway capabilities web; do
+ docker push "${JOBSCOUT_RELEASE_PREFIX}-${component}:${JOBSCOUT_RELEASE_TAG}"
+ done
+ echo "Published image tag: \`${JOBSCOUT_RELEASE_TAG}\` (linux/amd64)" >> "$GITHUB_STEP_SUMMARY"
- name: Service diagnostics
if: failure()
- run: docker compose --project-name jobscout --env-file .jobscout/.env -f docker/docker-compose.jobscout.yaml logs --tail 80
+ run: |
+ python - <<'PY'
+ import importlib.util
+ from pathlib import Path
+ spec = importlib.util.spec_from_file_location('deploy', 'scripts/jobscout.py')
+ deploy = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(deploy)
+ deploy.compose(Path('.jobscout').resolve(), ['logs', '--tail', '80'])
+ PY
- name: Stop disposable services
if: always()
run: python scripts/jobscout.py down
diff --git a/README.md b/README.md
index 5f506d32..74c5c11f 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,8 @@ python scripts/jobscout.py up
打开 **http://localhost:2026**,创建管理员账号即可进入工作台。首次构建会下载依赖并编译上游能力中心;命令会等待健康检查通过后才报告启动成功。
+服务器也可以直接拉取 **按完整提交号标记的预构建镜像**,省去现场安装依赖和编译。在 `.jobscout/.env` 设置 `JOBSCOUT_DEPLOY_MODE=images` 与已发布的 `JOBSCOUT_IMAGE_TAG=sha-完整提交号`,再运行同一个 `up` 命令。镜像模式要求 Compose 2.24.4+,当前发布 `linux/amd64`;具体版本获取、访问权限和升级步骤见[镜像部署](docs/jobscout/deployment.md#使用预构建镜像)。
+
这套入口会为你准备:
- **独立配置目录:** `.jobscout/`,初始化不覆盖原来根目录的本地开发配置。
@@ -193,6 +195,8 @@ Linux/macOS 将 Python 路径替换为 `backend/.venv/bin/python`,或在 backe
[部署 CI](.github/workflows/jobscout-deploy.yml) 在临时 Linux 实例验证镜像构建、首次初始化、同域认证、持久化与 Chromium 启动;它不代表真实招聘网站和模型服务已经验收。前端测试还覆盖注册策略、连接重试、桌面/移动端、任务并发、取消及历史记录操作。
+投递工作台按 **每页 50 条**加载,支持跨页环节筛选;新增记录不会把后续页面的游标挤乱。顶部总数与状态统计仍覆盖全部投递,CSV 导出与批量检查也保持完整范围。请求处理、分页状态、报告渲染与证据展示已拆为独立模块,页面仍无需构建即可运行。实现与性能边界见[可靠性记录](docs/jobscout/reliability.md)。
+
## 继续了解
| 想了解什么 | 文档 |
diff --git a/backend/app/application_tracker/AGENTS.md b/backend/app/application_tracker/AGENTS.md
index bced136e..e9922299 100644
--- a/backend/app/application_tracker/AGENTS.md
+++ b/backend/app/application_tracker/AGENTS.md
@@ -1,5 +1,10 @@
# Application tracker
+`pagination.py` returns owner-scoped cursor pages: only page rows load full evidence;
+global summaries scan compact state. Reuse MailStore resolution for mail conflicts
+and manual stages. Keep the legacy full list for exports/batches; no count claims
+based on the current page. Cursor mutation and page/full-list parity tests are offline.
+
Refresh writes compare revision, identity and checked_at inside BEGIN IMMEDIATE; preserve notification/history atomicity. Manual, batch and scheduled checks share the per-loop refresh lock. Connections close on context exit. Gateway caches store initialization off-loop. CSV export/import share versioned text encoding and bilingual column mapping; never import status history from a CSV.
Stage 7: `scheduling.py` owns owner-scoped settings, atomic per-day reservations and transactional notices. `scheduled_graph.py` accepts only the gated, trusted native scheduler context; never assemble the lead agent here. `allow_model_fallback=False` must avoid model initialization on every tracker branch. Cancellation/failure consumes reserved quota. Portal notices use the check transaction; mail notices share source resolution and the mail transaction. See `docs/jobscout/scheduled-refresh.md`.
diff --git a/backend/app/application_tracker/email/store.py b/backend/app/application_tracker/email/store.py
index d0df3667..f72126d5 100644
--- a/backend/app/application_tracker/email/store.py
+++ b/backend/app/application_tracker/email/store.py
@@ -1,6 +1,7 @@
"""Private mail ledger and a derived view; portal history is never overwritten."""
import json
+from collections import defaultdict
from datetime import UTC, datetime
from hashlib import sha256
@@ -41,13 +42,13 @@ def save(self, user_id: str, message: MailMessage, event: MailEvent) -> bool:
if row is None or row["company"] != event.company or row["role"] != event.role or not all(is_evidence_grounded(source, text) for text in (event.company, event.role)):
raise ValueError("Mail application ownership or evidence mismatch")
app = self.tracker._application_from_row(row)
- previous = self._decorate([app], self._events(connection, user_id))[0]
+ previous = self._decorate([app], self._events(connection, user_id, application_ids=[app.id]))[0]
result = connection.execute(
"INSERT OR IGNORE INTO jobscout_mail_events(user_id,application_id,fingerprint,event_json,received_at) VALUES(?,?,?,?,?)",
(user_id, event.application_id, fingerprint, event.model_dump_json(), event.received_at.astimezone(UTC).isoformat()),
)
if result.rowcount and previous is not None:
- current = self._decorate([app], self._events(connection, user_id))[0]
+ current = self._decorate([app], self._events(connection, user_id, application_ids=[app.id]))[0]
before = (previous.source_summary or {}).get("status", previous.status.value)
after = current.source_summary or {}
if before != "未知" and after.get("source") == "email" and not after.get("conflict") and after["status"] != before:
@@ -69,26 +70,36 @@ def list_events(self, user_id: str, application_id: int | None = None) -> list[d
def decorate(self, user_id: str, applications: list):
# One bounded query per user listing, not per application.
with self.tracker._connect() as connection:
- events = self._events(connection, self.tracker._validated_user_id(user_id))
+ events = self._events(connection, self.tracker._validated_user_id(user_id), application_ids=[app.id for app in applications])
return self._decorate(applications, events)
@staticmethod
- def _events(connection, user_id):
+ def _events(connection, user_id, *, application_ids=None, compact=False):
+ if application_ids == []:
+ return []
+ # Bind at most one page's IDs; full export keeps the original owner query.
+ bounded_ids = application_ids if application_ids is not None and len(application_ids) <= 100 else None
+ predicate = " AND application_id IN (" + ",".join("?" for _ in bounded_ids) + ")" if bounded_ids else ""
+ projection = "json_object('application_id',application_id,'status',json_extract(event_json,'$.status'),'review_reason',NULL,'received_at',json_extract(event_json,'$.received_at'))" if compact else "event_json"
rows = connection.execute(
- """SELECT id,event_json FROM (
- SELECT id,event_json,DENSE_RANK() OVER (PARTITION BY application_id ORDER BY received_at DESC) AS n
- FROM jobscout_mail_events WHERE user_id=? AND application_id IS NOT NULL
+ f"""SELECT id,event_json FROM (
+ SELECT id,{projection} AS event_json,DENSE_RANK() OVER (PARTITION BY application_id ORDER BY received_at DESC) AS n
+ FROM jobscout_mail_events WHERE user_id=? AND application_id IS NOT NULL {predicate}
AND json_extract(event_json,'$.review_reason') IS NULL AND json_extract(event_json,'$.status') IS NOT NULL
) WHERE n <= 2 ORDER BY id DESC""",
- (user_id,),
+ (user_id, *(bounded_ids or [])),
).fetchall()
return sorted([{"id": row["id"], **json.loads(row["event_json"])} for row in rows], key=lambda event: (datetime.fromisoformat(event["received_at"]), event["id"]), reverse=True)
@staticmethod
def _decorate(applications, events):
+ by_application = defaultdict(list)
+ for event in events:
+ if event["status"] and not event["review_reason"]:
+ by_application[event["application_id"]].append(event)
results = []
for app in applications:
- candidates = [event for event in events if event["application_id"] == app.id and event["status"] and not event["review_reason"]]
+ candidates = by_application[app.id]
if not candidates:
results.append(app)
continue
@@ -108,10 +119,10 @@ def _decorate(applications, events):
"source": "email" if selected else "portal",
"conflict": conflict,
"email_status": status,
- "evidence": latest["quote"],
+ "evidence": latest.get("quote", ""),
"received_at": latest["received_at"],
- "event_at": latest["event_at"],
- "time_evidence": latest["time_quote"],
+ "event_at": latest.get("event_at"),
+ "time_evidence": latest.get("time_quote", ""),
"event_id": latest["id"],
}
results.append(app.model_copy(update={"source_summary": summary}))
diff --git a/backend/app/application_tracker/pagination.py b/backend/app/application_tracker/pagination.py
new file mode 100644
index 00000000..20efa16f
--- /dev/null
+++ b/backend/app/application_tracker/pagination.py
@@ -0,0 +1,77 @@
+"""Bounded full-row pages with compact global status projections.
+
+Counts scan small status fields, never every application's evidence/notes. Mail
+resolution stays in MailStore so pagination cannot disagree with single reads.
+"""
+
+from collections import Counter
+from datetime import datetime
+
+from pydantic import BaseModel
+
+from .email.store import TERMINAL, MailStore
+from .models import ApplicationStatus, CheckResult
+
+
+class SummaryRow(BaseModel):
+ id: int
+ status: ApplicationStatus
+ stage: str
+ stage_manual: bool
+ confidence: float
+ checked_at: datetime | None
+ check_result: CheckResult | None
+ source_summary: dict | None = None
+
+
+def presentation(row):
+ source = row.source_summary or {}
+ mail = source.get("source") == "email" and not source.get("conflict")
+ status = source["status"] if mail else row.status.value
+ stage = source["status"] if mail and not row.stage_manual else row.stage or status
+ review = bool(source.get("conflict") or status == "未知" or (not mail and ((row.checked_at and row.confidence < 0.7) or (row.check_result and row.check_result is not CheckResult.SUCCESS))))
+ if review:
+ bucket = "review"
+ elif status == "Offer":
+ bucket = "offers"
+ elif status not in TERMINAL:
+ bucket = "active"
+ else:
+ bucket = None
+ return stage, bucket
+
+
+def list_page(store, user_id, *, limit=50, before_id=None, stage=None):
+ user_id = store._validated_user_id(user_id)
+ if not 1 <= limit <= 100 or (before_id is not None and before_id < 1):
+ raise ValueError("Invalid page bounds")
+ with store._connect() as connection:
+ connection.execute("BEGIN")
+ rows = connection.execute("SELECT id,status,stage,stage_manual,confidence,checked_at,check_result FROM applications WHERE user_id=? ORDER BY id DESC", (user_id,)).fetchall()
+ projected = MailStore._decorate([SummaryRow.model_validate(dict(row)) for row in rows], MailStore._events(connection, user_id, compact=True))
+ summary = {"total": len(projected), "active": 0, "review": 0, "offers": 0}
+ stages, matching = Counter(), []
+ for row in projected:
+ label, bucket = presentation(row)
+ stages[label] += 1
+ if bucket:
+ summary[bucket] += 1
+ if stage is None or label == stage:
+ matching.append(row.id)
+ eligible = [row_id for row_id in matching if before_id is None or row_id < before_id]
+ ids = eligible[:limit]
+ items = []
+ if ids:
+ placeholders = ",".join("?" for _ in ids)
+ selected = connection.execute(f"SELECT * FROM applications WHERE user_id=? AND id IN ({placeholders}) ORDER BY id DESC LIMIT ?", (user_id, *ids, limit)).fetchall()
+ items = MailStore._decorate([store._application_from_row(row) for row in selected], MailStore._events(connection, user_id, application_ids=ids))
+ has_more = len(eligible) > limit
+ return {
+ "items": items,
+ "total": summary["total"],
+ "filtered_total": len(matching),
+ "summary": summary,
+ "stages": [{"label": label, "count": count} for label, count in stages.items()],
+ "has_more": has_more,
+ "next_before_id": ids[-1] if has_more else None,
+ }
diff --git a/backend/app/application_tracker/store.py b/backend/app/application_tracker/store.py
index fc02950c..fdc51f9f 100644
--- a/backend/app/application_tracker/store.py
+++ b/backend/app/application_tracker/store.py
@@ -627,6 +627,11 @@ def import_applications(
updated += 1
return ImportSummary(inserted=inserted, updated=updated, total=len(applications))
+ def list_applications_page(self, user_id: str, *, limit=50, before_id=None, stage=None) -> dict:
+ from .pagination import list_page
+
+ return list_page(self, user_id, limit=limit, before_id=before_id, stage=stage)
+
def list_applications(self, user_id: str) -> list[StoredApplication]:
user_id = self._validated_user_id(user_id)
with self._connect() as connection:
diff --git a/backend/app/gateway/routers/jobscout.py b/backend/app/gateway/routers/jobscout.py
index b1480e32..26b7e978 100644
--- a/backend/app/gateway/routers/jobscout.py
+++ b/backend/app/gateway/routers/jobscout.py
@@ -9,7 +9,7 @@
from threading import Lock
from anyio import CancelScope
-from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
+from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from fastapi.encoders import jsonable_encoder
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel, Field, field_validator, model_validator
@@ -515,6 +515,13 @@ async def list_tracker_applications(request: Request) -> list[StoredApplication]
return await asyncio.to_thread((await _get_tracker_store()).list_applications, user_id)
+@router.get("/tracker/applications/page")
+@require_permission("runs", "read")
+async def list_tracker_applications_page(request: Request, limit: int = Query(50, ge=1, le=100), before_id: int | None = Query(None, ge=1), stage: str | None = Query(None, max_length=60)) -> dict:
+ del request
+ return await asyncio.to_thread((await _get_tracker_store()).list_applications_page, get_effective_user_id(), limit=limit, before_id=before_id, stage=stage)
+
+
@router.post("/tracker/applications", response_model=StoredApplication)
@require_permission("runs", "create")
async def create_tracker_application(body: TrackerApplicationCreate, request: Request) -> StoredApplication:
diff --git a/backend/tests/test_jobscout_deployment.py b/backend/tests/test_jobscout_deployment.py
index cb561311..c35d0fef 100644
--- a/backend/tests/test_jobscout_deployment.py
+++ b/backend/tests/test_jobscout_deployment.py
@@ -77,6 +77,50 @@ def test_compose_uses_explicit_env_and_project_directory(tmp_path):
assert command[-2:] == ["config", "--quiet"]
+@pytest.mark.parametrize("offline", [False, True])
+def test_image_mode_uses_override_and_never_builds_on_up(tmp_path, monkeypatch, offline):
+ cli = load_cli()
+ cli.initialize(tmp_path)
+ env = tmp_path / ".env"
+ env.write_text(env.read_text(encoding="utf-8") + "\nJOBSCOUT_DEPLOY_MODE=images\nJOBSCOUT_IMAGE_TAG=sha-" + "a" * 40 + "\n", encoding="utf-8")
+ command = cli.compose_command(tmp_path, ["up"])
+ assert any(item.endswith("docker-compose.jobscout-images.yaml") for item in command)
+ monkeypatch.setattr(cli.sys, "argv", ["jobscout.py", "up", "--directory", str(tmp_path), *(["--no-pull"] if offline else [])])
+ monkeypatch.setattr(cli, "doctor", lambda _: True)
+ calls = []
+ monkeypatch.setattr(cli, "compose", lambda directory, args, **kwargs: calls.append(args) or SimpleNamespace(returncode=0))
+ assert cli.main() == 0
+ assert calls[0][0] == ("up" if offline else "pull")
+ assert "--no-build" in calls[-1] and "--build" not in calls[-1]
+ assert calls[-1][calls[-1].index("--pull") + 1] == "never"
+
+
+def test_image_mode_requires_a_commit_tag(tmp_path):
+ cli = load_cli()
+ cli.initialize(tmp_path)
+ env = tmp_path / ".env"
+ env.write_text(env.read_text(encoding="utf-8") + "\nJOBSCOUT_DEPLOY_MODE=images\nJOBSCOUT_IMAGE_TAG=latest\n", encoding="utf-8")
+ assert any("JOBSCOUT_IMAGE_TAG" in error for error in cli.configuration_errors(tmp_path))
+
+
+def test_image_pull_failure_does_not_start_partial_release(tmp_path, monkeypatch):
+ cli = load_cli()
+ cli.initialize(tmp_path)
+ env = tmp_path / ".env"
+ env.write_text(env.read_text(encoding="utf-8") + "\nJOBSCOUT_DEPLOY_MODE=images\n", encoding="utf-8")
+ monkeypatch.setattr(cli.sys, "argv", ["jobscout.py", "up", "--directory", str(tmp_path)])
+ monkeypatch.setattr(cli, "doctor", lambda _: True)
+ calls = []
+
+ def run(directory, arguments, **kwargs):
+ calls.append(arguments)
+ raise subprocess.CalledProcessError(1, arguments)
+
+ monkeypatch.setattr(cli, "compose", run)
+ assert cli.main() == 1
+ assert calls == [["pull"]]
+
+
def test_deployment_config_passes_real_schema(tmp_path, monkeypatch):
cli = load_cli()
cli.initialize(tmp_path)
diff --git a/backend/tests/test_jobscout_pagination.py b/backend/tests/test_jobscout_pagination.py
new file mode 100644
index 00000000..21f324ec
--- /dev/null
+++ b/backend/tests/test_jobscout_pagination.py
@@ -0,0 +1,93 @@
+"""Synthetic pages retain global counts and owner isolation across mutations."""
+
+import json
+
+import pytest
+from fastapi.testclient import TestClient
+
+from app.application_tracker.models import ApplicationInput
+from app.application_tracker.store import ApplicationTrackerStore
+
+
+@pytest.fixture
+def tracker(tmp_path):
+ store = ApplicationTrackerStore(tmp_path / "tracker.db")
+ store.import_applications("owner", [ApplicationInput(company="Example", role=f"Role {i}", url=f"https://jobs.example.com/{i}") for i in range(123)])
+ store.add_application("foreign", ApplicationInput(company="Private", role="Private", url="https://jobs.example.com/private"))
+ return store
+
+
+def test_pages_are_bounded_and_cursor_survives_inserts_and_deletes(tracker, monkeypatch):
+ monkeypatch.setattr(tracker, "list_applications", lambda *args: pytest.fail("Do not load complete rows before slicing"))
+ first = tracker.list_applications_page("owner", limit=50)
+ assert len(first["items"]) == 50 and first["total"] == 123 and first["has_more"]
+ assert first["summary"] == {"total": 123, "active": 0, "review": 123, "offers": 0}
+ assert first["items"][0].id == 123
+ tracker.delete_application("owner", 123)
+ tracker.add_application("owner", ApplicationInput(company="New", role="New", url="https://jobs.example.com/new"))
+ second = tracker.list_applications_page("owner", limit=50, before_id=first["next_before_id"])
+ third = tracker.list_applications_page("owner", limit=50, before_id=second["next_before_id"])
+ ids = [row.id for page in (first, second, third) for row in page["items"]]
+ assert ids == list(range(123, 0, -1)) and not third["has_more"]
+ assert all(row.company != "Private" for page in (first, second, third) for row in page["items"])
+
+
+def test_filter_and_summary_include_mail_and_manual_stages(tracker):
+ with tracker._connect() as connection:
+ connection.execute("UPDATE applications SET stage='准备材料',stage_manual=1 WHERE id=1")
+ event = {"application_id": 2, "status": "Offer", "review_reason": None, "received_at": "2026-10-05T00:00:00+00:00", "quote": "Synthetic Offer", "event_at": None, "time_quote": ""}
+ connection.execute("INSERT INTO jobscout_mail_events(user_id,application_id,fingerprint,event_json,received_at) VALUES(?,?,?,?,?)", ("owner", 2, "fixture", json.dumps(event), event["received_at"]))
+ result = tracker.list_applications_page("owner", stage="Offer", limit=10)
+ assert [row.id for row in result["items"]] == [2]
+ assert result["items"][0].source_summary["source"] == "email"
+ assert result["summary"] == {"total": 123, "active": 0, "review": 122, "offers": 1}
+ assert result["filtered_total"] == 1
+ assert {item["label"]: item["count"] for item in result["stages"]} == {"未知": 121, "准备材料": 1, "Offer": 1}
+ assert [row.id for row in tracker.list_applications_page("owner", stage="准备材料")["items"]] == [1]
+ assert tracker.list_applications_page("owner", stage="Missing")["items"] == []
+
+
+def test_page_only_materializes_selected_full_rows(tracker, monkeypatch):
+ original = tracker._application_from_row
+ materialized = []
+
+ def record(row):
+ materialized.append(row["id"])
+ return original(row)
+
+ monkeypatch.setattr(tracker, "_application_from_row", record)
+ page = tracker.list_applications_page("owner", limit=3)
+ assert materialized == [123, 122, 121]
+ assert page["total"] == 123
+
+
+def test_conflicting_mail_has_identical_page_and_full_list_view(tracker):
+ with tracker._connect() as connection:
+ for status in ("Offer", "未通过"):
+ event = {"application_id": 123, "status": status, "review_reason": None, "received_at": "2026-10-05T00:00:00+00:00", "quote": "Synthetic " + status}
+ connection.execute("INSERT INTO jobscout_mail_events(user_id,application_id,fingerprint,event_json,received_at) VALUES(?,?,?,?,?)", ("owner", 123, status, json.dumps(event), event["received_at"]))
+ page = tracker.list_applications_page("owner", limit=1, stage="未知")
+ full = next(row for row in tracker.list_applications("owner") if row.id == 123)
+ assert page["items"][0] == full
+ assert full.source_summary["conflict"]
+ assert page["summary"]["review"] == 123
+ assert page["summary"]["offers"] == 0
+
+
+def test_page_api_bounds_and_auth(tracker, monkeypatch):
+ from _router_auth_helpers import make_authed_test_app
+
+ from app.gateway.routers import jobscout
+
+ monkeypatch.setattr(jobscout, "_tracker_store", lambda: tracker)
+ monkeypatch.setattr(jobscout, "get_effective_user_id", lambda: "owner")
+ app = make_authed_test_app()
+ app.include_router(jobscout.router)
+ client = TestClient(app)
+ path = "/api/jobscout/tracker/applications/page"
+ assert client.get(path + "?limit=101").status_code == 422
+ assert client.get(path + "?limit=0").status_code == 422
+ assert client.get(path + "?before_id=-1").status_code == 422
+ response = client.get(path + "?limit=3")
+ assert response.status_code == 200
+ assert len(response.json()["items"]) == 3 and response.json()["total"] == 123
diff --git a/deploy/jobscout/env.example b/deploy/jobscout/env.example
index 8dff8ba7..65e388da 100644
--- a/deploy/jobscout/env.example
+++ b/deploy/jobscout/env.example
@@ -16,3 +16,9 @@ AUTH_JWT_SECRET=__GENERATE__
BETTER_AUTH_SECRET=__GENERATE__
DEER_FLOW_INTERNAL_AUTH_TOKEN=__GENERATE__
JOBSCOUT_DATA_VOLUME=jobscout_jobscout-data
+
+# source builds this checkout; images pulls the three prebuilt application images.
+JOBSCOUT_DEPLOY_MODE=source
+JOBSCOUT_IMAGE_PREFIX=ghcr.io/funkierry/jobscout
+# Image mode requires sha- followed by the complete 40-character published commit.
+JOBSCOUT_IMAGE_TAG=
diff --git a/deploy/jobscout/gateway.Dockerfile b/deploy/jobscout/gateway.Dockerfile
new file mode 100644
index 00000000..b1e0e969
--- /dev/null
+++ b/deploy/jobscout/gateway.Dockerfile
@@ -0,0 +1,5 @@
+# Wrap the browser-enabled backend image built and tested by source Compose.
+ARG BACKEND_IMAGE=jobscout-gateway:latest
+FROM ${BACKEND_IMAGE}
+COPY skills/public /app/skills/public
+COPY LICENSE /app/LICENSE
diff --git a/deploy/jobscout/gateway.Dockerfile.dockerignore b/deploy/jobscout/gateway.Dockerfile.dockerignore
new file mode 100644
index 00000000..87e5929e
--- /dev/null
+++ b/deploy/jobscout/gateway.Dockerfile.dockerignore
@@ -0,0 +1,5 @@
+**
+!skills/
+!skills/public/
+!skills/public/**
+!LICENSE
diff --git a/deploy/jobscout/web.Dockerfile b/deploy/jobscout/web.Dockerfile
new file mode 100644
index 00000000..69c87ace
--- /dev/null
+++ b/deploy/jobscout/web.Dockerfile
@@ -0,0 +1,4 @@
+FROM nginx:stable-alpine
+COPY docker/nginx/jobscout.conf /etc/nginx/nginx.conf
+COPY jobscout-web/index.html jobscout-web/style.css jobscout-web/runtime-config.js jobscout-web/app.js jobscout-web/core.js jobscout-web/api-client.js jobscout-web/tracker-state.js /usr/share/nginx/jobscout/
+COPY LICENSE /usr/share/nginx/jobscout/LICENSE
diff --git a/deploy/jobscout/web.Dockerfile.dockerignore b/deploy/jobscout/web.Dockerfile.dockerignore
new file mode 100644
index 00000000..5bab096e
--- /dev/null
+++ b/deploy/jobscout/web.Dockerfile.dockerignore
@@ -0,0 +1,13 @@
+**
+!docker/
+!docker/nginx/
+!docker/nginx/jobscout.conf
+!jobscout-web/
+!jobscout-web/index.html
+!jobscout-web/style.css
+!jobscout-web/runtime-config.js
+!jobscout-web/app.js
+!jobscout-web/core.js
+!jobscout-web/api-client.js
+!jobscout-web/tracker-state.js
+!LICENSE
diff --git a/docker/docker-compose.jobscout-images.yaml b/docker/docker-compose.jobscout-images.yaml
new file mode 100644
index 00000000..8a650c6a
--- /dev/null
+++ b/docker/docker-compose.jobscout-images.yaml
@@ -0,0 +1,18 @@
+# Requires Docker Compose >= 2.24.4; merged over docker-compose.jobscout.yaml.
+# The application and public skills live inside the images. Only operator
+# configuration and persistent data are mounted from outside them.
+services:
+ gateway:
+ build: !reset null
+ image: ${JOBSCOUT_IMAGE_PREFIX:?Set JOBSCOUT_IMAGE_PREFIX}-gateway:${JOBSCOUT_IMAGE_TAG:?Set JOBSCOUT_IMAGE_TAG}
+ volumes: !override
+ - ${JOBSCOUT_DEPLOY_DIR:-../.jobscout}/config.yaml:/config/config.yaml:ro
+ - ${JOBSCOUT_DEPLOY_DIR:-../.jobscout}/extensions_config.json:/config/extensions_config.json
+ - jobscout-data:/data
+ frontend:
+ build: !reset null
+ image: ${JOBSCOUT_IMAGE_PREFIX:?Set JOBSCOUT_IMAGE_PREFIX}-capabilities:${JOBSCOUT_IMAGE_TAG:?Set JOBSCOUT_IMAGE_TAG}
+ nginx:
+ image: ${JOBSCOUT_IMAGE_PREFIX:?Set JOBSCOUT_IMAGE_PREFIX}-web:${JOBSCOUT_IMAGE_TAG:?Set JOBSCOUT_IMAGE_TAG}
+ volumes: !override
+ - ${JOBSCOUT_DEPLOY_DIR:-../.jobscout}/runtime-config.js:/usr/share/nginx/jobscout/runtime-config.js:ro
diff --git a/docker/nginx/jobscout.conf b/docker/nginx/jobscout.conf
index 1549e849..2449fa1e 100644
--- a/docker/nginx/jobscout.conf
+++ b/docker/nginx/jobscout.conf
@@ -31,6 +31,7 @@ http {
location = / { try_files /index.html =404; add_header Cache-Control "no-cache"; }
location = /index.html { add_header Cache-Control "no-cache"; }
location = /app.js { add_header Cache-Control "no-cache"; }
+ location ~ ^/(core|api-client|tracker-state)\.js$ { try_files $uri =404; add_header Cache-Control "no-cache"; }
location = /style.css { add_header Cache-Control "no-cache"; }
location = /runtime-config.js { add_header Cache-Control "no-store"; }
location /api/langgraph/ {
diff --git a/docs/jobscout/deployment.md b/docs/jobscout/deployment.md
index b6cc9ebf..d16d103b 100644
--- a/docs/jobscout/deployment.md
+++ b/docs/jobscout/deployment.md
@@ -31,6 +31,33 @@ python scripts/jobscout.py up
打开 **http://localhost:2026**。新实例会直接显示“创建你的管理员账号”;创建完成后进入 JobScout。公开注册默认关闭,已有实例显示登录入口。
+### 使用预构建镜像
+
+资源有限的服务器可跳过本机构建。先按上面的步骤克隆仓库、执行 `init`、填写模型参数,再编辑 `.jobscout/.env`:
+
+```dotenv
+JOBSCOUT_DEPLOY_MODE=images
+JOBSCOUT_IMAGE_PREFIX=ghcr.io/funkierry/jobscout
+JOBSCOUT_IMAGE_TAG=sha-这里替换为已发布版本的完整40位提交号
+```
+
+在 GitHub 的 [JobScout deployment 工作流](https://github.com/Funkierry/JobScout/actions/workflows/jobscout-deploy.yml) 找到 **main 分支成功完成发布**的一次运行,从运行摘要复制 `sha-…` 标签。PR 检查仅构建和验证,不发布;main 推送或 main 上手动重跑会在容器验证通过后发布以下三个镜像,标签必须一致:
+
+- `ghcr.io/funkierry/jobscout-gateway`:后端、Chromium、私有浏览器桌面和内置公开技能。
+- `ghcr.io/funkierry/jobscout-capabilities`:飞书连接与上游能力中心。
+- `ghcr.io/funkierry/jobscout-web`:独立工作台页面及 Nginx 配置。
+
+当前 CI 构建 `linux/amd64`。ARM 主机可采用源码构建;此处尚未提供原生 ARM 镜像。镜像模式使用 Compose 的卷替换语法,需要 **Compose 2.24.4 或更新版本**,`doctor` 会检查。[Compose 合并规则](https://docs.docker.com/reference/compose-file/merge/)。
+
+```bash
+python scripts/jobscout.py doctor
+python scripts/jobscout.py up
+```
+
+`up` 会先拉取所有镜像,再以 `--no-build` 启动并等待健康检查。应用源码、公开技能和网页都已打包;服务器只挂载私有配置、公开运行时地址和数据卷。升级时先备份,再将代码与 `JOBSCOUT_IMAGE_TAG` 切到同一个已发布提交后运行 `up`。恢复旧版本也应配合兼容的数据备份,单纯切镜像不能撤销数据库迁移。
+
+GHCR 首次发布的包可能是私有包;仓库公开不代表镜像自动公开。遇到 `denied` 时,仓库所有者可在包设置中开放可见性,或部署者使用具备该包读取权限的账号通过 `docker login ghcr.io` 登录。访问令牌放 Docker 凭据存储,不放网页配置。[GHCR 访问说明](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry)。已离线导入镜像时可用 `up --no-pull`;平常使用默认拉取即可。
+
## 2. 为什么保留四个服务
| 服务 | 职责 | 宿主机访问 |
@@ -118,7 +145,7 @@ python scripts/jobscout.py up
命令要求 Gateway 已停止,并拒绝向非空卷写入。恢复前使用原版本镜像/代码,确认账号、投递和文件后再升级。归档路径会先校验,再在临时目录解包;原数据卷一直保留,切回原卷名即可重新启动旧数据。第一次运行恢复命令需要本地已有构建镜像。Redis 是事件传输缓存,不在数据备份中;持久会话和运行记录由 `/data` 数据库恢复。
-更新代码后再次运行 `python scripts/jobscout.py up` 即可重建并检查健康状态。更新前先备份;有数据库迁移的版本应结合对应变更说明安排回退。
+源码模式更新代码后再次运行 `python scripts/jobscout.py up` 即可重建并检查健康状态;镜像模式按上面的步骤更新发布标签。更新前先备份;有数据库迁移的版本应结合对应变更说明安排回退。
## 6. 可选功能与费用
@@ -143,6 +170,8 @@ python scripts/jobscout.py up
[JobScout deployment CI](../../.github/workflows/jobscout-deploy.yml) 在临时 Linux 环境构建四个服务,检查入口、管理员初始化、Cookie/CSRF、关闭注册、目标岗位保存、能力中心、Chromium 启动,以及容器停止再启动后的账号持久化。测试使用合成账号,不调用模型或真实招聘网站。
+随后 CI 会备份并恢复到新卷,再将应用打包为三个镜像,在另一个全新数据卷启动镜像模式,复测初始化与接口,同时检查页面模块、内置技能以及不存在应用源码挂载。只有这些检查通过,main 上的运行才会推送提交标签;发布失败的运行不能作为可部署版本。
+
`scripts/jobscout_smoke.py` 会创建合成管理员,仅供全新、可丢弃的本地测试实例;不要在自己的正式实例运行。功能回归和离线评测见 [README](../../README.md#开发与验证)。
容器健康不等于模型研究质量或真实招聘网站兼容性。个人部署完成后,分别验收一次自己的岗位检查、面试准备和飞书读取,才能确认所选服务商与站点组合可用。
diff --git a/docs/jobscout/reliability.md b/docs/jobscout/reliability.md
index 1622f86e..e096c2da 100644
--- a/docs/jobscout/reliability.md
+++ b/docs/jobscout/reliability.md
@@ -11,11 +11,16 @@
| 连接和列表查询开销 | 事务结束关闭连接,缓存按路径初始化的 store;目标列表合并查询 | 12 个岗位从 37 次查询降为 3 次,账号隔离保持 |
| 备份缺少恢复入口 | 独立归档模块,恢复只写空卷,拒绝越界路径和特殊文件 | 本地归档往返、拒绝覆盖;Linux 容器恢复演练 |
| 本地通过但 CI 漏项 | 修复配置隔离/路由覆盖/UTF-8 读取;新增 standalone 浏览器门禁 | 上游 unit/lint/blocking-io + JobScout 专项 + 部署 CI |
+| 投递列表一次加载全部内容 | 默认 50、上限 100 条的 ID 游标分页;全局统计与环节筛选保留邮件/手动状态语义 | 123 条合成记录跨页、插入/删除、账号隔离、冲突邮件与失败重试 |
+| 前端请求和展示逻辑混在一起 | 提取 `api-client.js`、`core.js`、`tracker-state.js`;无构建流程 | CSRF、取消/超时、报告来源校验、过期响应、桌面/移动端翻页 |
+| 服务器必须安装依赖并编译 | CI 打包三个应用镜像,按完整提交号发布;CLI 自动选用镜像覆盖配置 | 拉取失败停止启动、容器健康检查、无源码挂载的首次启动 |
CSV 对公式形态字段加制表符文本前缀并整体加引号,版本标记帮助导入恢复原值;不同表格软件可能重新解释保存后的内容,修改过的第三方文件应以实际导入结果为准。导入处理的是岗位登记字段,历史检查和通知请通过数据卷备份恢复。
预算基于供应商响应中的用量,无法撤销已经发出的请求。单条投递检查有独立预算,批量刷新不是一个全局费用预算。单进程锁用于减少重复工作,SQLite 事务校验负责防止过期写入;当前部署仍要求一个 Gateway worker。
-代码拆分集中在 CSV、预算和归档这三个有清楚边界的模块。后续仍可做:长列表分页、独立拆分前端视图与状态管理、发布带固定版本的预构建镜像。真实站点覆盖率、完整流程耗时与模型成本评测仍按[评测记录](metrics.md)标为待运行,不能用本轮合成测试代替。
+列表通过 `/api/jobscout/tracker/applications/page` 获取一页完整记录,以 `before_id` 继续向较小 ID 翻页。统计和筛选仍扫描当前用户的紧凑状态字段及邮件状态投影,因此该部分开销随记录数量增长;完整证据、备注、响应数据和 DOM 行数则受到页大小限制。本轮没有把该查询描述成常数复杂度,也没有引入未经测量的响应耗时数字。全量列表保留给兼容调用,CSV 导出与批量刷新保持全部记录范围。
+
+前端提取了请求、纯展示函数和分页状态模块,视图与业务交互仍由 `app.js` 组织。后续可根据实际使用量继续拆分视图、优化全局统计查询及增加 ARM 镜像。真实站点覆盖率、完整流程耗时与模型成本评测仍按[评测记录](metrics.md)标为待运行,不能用本轮合成测试代替。
验证入口:`backend/scripts/jobscout_offline_tests.py`、`jobscout-web/test_*.py`、`jobscout-web/test_pure.js`;GitHub Actions 会保存每次运行的测试结果和容器日志。
diff --git a/jobscout-web/AGENTS.md b/jobscout-web/AGENTS.md
index 2782fee5..7cc472ae 100644
--- a/jobscout-web/AGENTS.md
+++ b/jobscout-web/AGENTS.md
@@ -18,6 +18,11 @@ All browser tests intercept requests and use synthetic data. Run `test_pure.js`,
with the backend Playwright environment. Keep runtime-config.js in their asset
fixtures. Live research and deployment smoke are separate, explicit operations.
+Load core.js, api-client.js and tracker-state.js before app.js; serve them in browser
+fixtures and Nginx. Tracker uses cursor pages (50 rows), server global counts and
+stage filters; CSV/batch remain complete. Failed navigation restores its committed
+cursor/filter. Session/request ownership guards must still reject late responses.
+
CSV import uses the visible button/file input and existing CSRF-aware API helper;
keep its browser test and busy-state behavior. JobScout regression CI runs all five
standalone test entrypoints, with every browser request intercepted.
diff --git a/jobscout-web/README.md b/jobscout-web/README.md
index e09da652..fdd80a26 100644
--- a/jobscout-web/README.md
+++ b/jobscout-web/README.md
@@ -37,6 +37,10 @@ style uses solid blue accents, neutral surfaces, clear type and borders. Summary
cards count all saved applications, confirmed active processes, review-needed
records and confirmed offers; they are calculated from current records, never
placeholder statistics. Unknown, conflicting and failed checks remain in review.
+The table loads 50 applications per page, newest IDs first. Previous/next navigation
+and stage filters use the server's cursor endpoint; summary cards and filter counts
+cover every saved application. Failed page requests retain the last successful
+page. CSV export and batch refresh continue to cover all records.
The left sidebar contains task-mode navigation, new-chat and
searchable DeerFlow thread history, plus the current account. The main area has
an empty-state prompt gallery, evidence/status header, inline reports, resume
@@ -82,6 +86,11 @@ than claiming the server stopped. Restart Gateway after updating the backend.
## Run locally
+`core.js` owns pure display/report helpers, `api-client.js` owns credentials,
+CSRF and request cancellation, and `tracker-state.js` owns pagination navigation.
+Load them before `app.js`, which connects those modules to the DOM and task state.
+These scripts also expose CommonJS exports for offline Node checks; no bundler is needed.
+
For a fresh machine or server, use the [Docker deployment guide](../docs/jobscout/deployment.md).
It serves this client at `/`, proxies the Gateway on the same origin, and retains
the upstream Capability Center for Feishu setup. Fresh instances create their
diff --git a/jobscout-web/api-client.js b/jobscout-web/api-client.js
new file mode 100644
index 00000000..35bd3eba
--- /dev/null
+++ b/jobscout-web/api-client.js
@@ -0,0 +1,56 @@
+// Same-origin credentials, CSRF and cancellation share one transport boundary.
+(function (root, factory) {
+ if (typeof module !== "undefined") module.exports = factory();
+ else root.JobScoutApi = factory();
+})(globalThis, function () {
+ function createApiClient({ base = "", fetcher, cookie = () => "" }) {
+ return async function request(
+ path,
+ {
+ method = "GET",
+ json,
+ form,
+ headers = {},
+ signal,
+ timeoutMs = 30000,
+ } = {},
+ ) {
+ const opts = { method, credentials: "include", headers: { ...headers } };
+ const signals = [
+ signal,
+ timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : null,
+ ].filter(Boolean);
+ if (signals.length)
+ opts.signal =
+ signals.length === 1 ? signals[0] : AbortSignal.any(signals);
+ if (json !== undefined) {
+ opts.headers["Content-Type"] = "application/json";
+ opts.body = JSON.stringify(json);
+ } else if (form !== undefined) opts.body = form;
+ if (method !== "GET" && method !== "HEAD") {
+ const match = cookie().match(/(?:^|; )csrf_token=([^;]*)/);
+ if (match) opts.headers["X-CSRF-Token"] = decodeURIComponent(match[1]);
+ }
+ return fetcher(base + path, opts);
+ };
+ }
+ async function decodeResponse(res) {
+ let body = null;
+ try {
+ body = await res.json();
+ } catch (_) {
+ /* no body */
+ }
+ if (!res.ok) {
+ const msg = body?.detail?.message || body?.detail || res.statusText;
+ const error = new Error(
+ typeof msg === "string" ? msg : JSON.stringify(msg),
+ );
+ error.status = res.status;
+ error.code = body?.detail?.code;
+ throw error;
+ }
+ return body;
+ }
+ return { createApiClient, decodeResponse };
+});
diff --git a/jobscout-web/app.js b/jobscout-web/app.js
index f3a02243..ead26deb 100644
--- a/jobscout-web/app.js
+++ b/jobscout-web/app.js
@@ -2,26 +2,14 @@
// No build step: plain fetch + manual SSE parsing against the documented
// Gateway shapes (login/CSRF, threads, uploads, runs/stream).
-function resolveDeploymentConfig(config = {}, location = {}) {
- const localSplit = location.protocol === "http:" && location.port === "5500"
- && ["localhost", "127.0.0.1", "[::1]"].includes(location.hostname);
- const localHost = location.hostname || "localhost";
- const gatewayBase = config.gatewayBase ?? (localSplit ? `http://${localHost}:8001` : "");
- const capabilityCenterUrl = config.capabilityCenterUrl
- ?? (localSplit ? `http://${localHost}:3000/workspace/capabilities` : "/workspace/capabilities");
- function validateUrl(value, allowPath) {
- if (typeof value !== "string") throw new Error("部署地址必须是字符串");
- if (allowPath && /^\/(?!\/)/.test(value) && !value.includes("\\")) return value;
- if (!value && !allowPath) return "";
- const parsed = new URL(value);
- if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password
- || (!allowPath && (parsed.pathname !== "/" || parsed.search || parsed.hash))) {
- throw new Error("部署地址必须是有效的 HTTP(S) 地址,且不能包含账号密码");
- }
- return allowPath ? parsed.href : parsed.origin;
- }
- return { gatewayBase: validateUrl(gatewayBase, false), capabilityCenterUrl: validateUrl(capabilityCenterUrl, true) };
-}
+const {
+ resolveDeploymentConfig, isTerminalTrackerStatus, trackerDisplayRow,
+ trackerRowPresentation, trackerSummary, trackerStageWaitText, trackerStageLabel,
+ trackerStageFilterOptions, filterTrackerRows, parseSseFrames, jobScoutRunContext,
+ guardedMessageText, contentToText, extractLastVisibleAiText, looksLikeReport,
+ sanitizeJobScoutReportMarkdown, normalizeReportMarkdownForRender,
+ escapeHtml, inlineMd, markdownTableCells, markdownToHtml,
+} = typeof require === "function" ? require("./core.js") : globalThis.JobScoutCore;
const DEPLOYMENT = resolveDeploymentConfig(
typeof window !== "undefined" ? window.JOBSCOUT_CONFIG || {} : {},
@@ -29,144 +17,13 @@ const DEPLOYMENT = resolveDeploymentConfig(
);
const GATEWAY_BASE = DEPLOYMENT.gatewayBase;
-const TERMINAL_TRACKER_STATUSES = new Set(["Offer", "未通过", "流程终止"]);
-const STOPPED_TRACKER_STATUSES = new Set(["未通过", "流程终止"]);
-
-function isTerminalTrackerStatus(status) {
- return TERMINAL_TRACKER_STATUSES.has(status);
-}
-
-function trackerDisplayRow(row) {
- const source = row?.source_summary;
- if (!source || source.conflict || source.source !== "email") return row;
- return { ...row, portal_status: row.status, portal_evidence: row.evidence, status: source.status,
- stage: row.stage_manual ? row.stage : source.status, raw_status: source.status,
- evidence: source.evidence, checked_at: source.received_at, changed_at: source.received_at,
- check_result: "成功", confidence: 1, changed: false };
-}
-
-function trackerRowPresentation(row) {
- const terminal = Boolean(row?.terminal) || isTerminalTrackerStatus(row?.status);
- const needsReview = Boolean(row?.checked_at) && Number(row?.confidence) < 0.7;
- const changed = Boolean(row?.changed && row?.previous_status);
- const stopped = STOPPED_TRACKER_STATUSES.has(row?.status) || STOPPED_TRACKER_STATUSES.has(row?.stage);
- const unknown = needsReview || !row?.status || row.status === "未知" || (row.check_result && row.check_result !== "成功");
- return {
- terminal,
- canRefresh: !terminal,
- needsReview,
- changeText: changed ? `${row.previous_status} → ${row.status}` : "",
- tone: changed ? "changed" : (needsReview ? "review" : "normal"),
- statusTone: stopped ? "stopped" : (unknown ? "unknown" : "active"),
- };
-}
-
-function trackerSummary(rows) {
- const summary = { total: rows.length, active: 0, review: 0, offers: 0 };
- for (const stored of rows) {
- const row = trackerDisplayRow(stored);
- const presentation = trackerRowPresentation(row);
- const review = row.source_summary?.conflict || presentation.needsReview || !row.status || row.status === "未知" || (row.check_result && row.check_result !== "成功");
- if (review) summary.review += 1;
- else if (row.status === "Offer") summary.offers += 1;
- else if (!presentation.terminal) summary.active += 1;
- }
- return summary;
-}
-
-function trackerStageWaitText(row, now = new Date()) {
- if (!row?.status || row.status === "未知") return "阶段待确认";
- if (row.terminal || isTerminalTrackerStatus(row.status)) return "流程已结束";
- if (row.stage_manual) return "手动环节,起点待确认";
- if (!row.changed_at) return "等待首次检查";
- const started = new Date(row.changed_at);
- const elapsed = now.getTime() - started.getTime();
- if (!Number.isFinite(elapsed) || elapsed < 0) return "起点时间待确认";
- const days = Math.floor(elapsed / 86400000);
- return days < 1 ? "不足 1 天(自首次识别)" : `至少 ${days} 天(自首次识别)`;
-}
-
-function trackerStageLabel(row) {
- row = trackerDisplayRow(row);
- return row?.stage || row?.status || "未分类";
-}
-
-function trackerStageFilterOptions(rows, stages = []) {
- const counts = new Map();
- for (const row of rows) {
- const label = trackerStageLabel(row);
- counts.set(label, (counts.get(label) || 0) + 1);
- }
- const ordered = [...new Set(stages)].filter((label) => counts.has(label));
- for (const label of counts.keys()) {
- if (!ordered.includes(label)) ordered.push(label);
- }
- return ordered.map((label) => ({ label, count: counts.get(label) }));
-}
-
-function filterTrackerRows(rows, stage) {
- return stage === null ? rows : rows.filter((row) => trackerStageLabel(row) === stage);
-}
-
-function parseSseFrames(buffer) {
- const normalized = String(buffer || "").replace(/\r\n/g, "\n");
- const frames = normalized.split("\n\n");
- const remainder = frames.pop() || "";
- const events = [];
- for (const frame of frames) {
- const data = frame
- .split("\n")
- .filter((line) => line.startsWith("data:"))
- .map((line) => line.slice(5).trimStart())
- .join("\n");
- if (data) events.push(JSON.parse(data));
- }
- return { events, remainder };
-}
-
-// ---------------------------------------------------------------- helpers --
-
function $(id) { return document.getElementById(id); }
-function getCookie(name) {
- const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
- return match ? decodeURIComponent(match[1]) : null;
-}
-
-async function api(path, { method = "GET", json, form, headers = {}, signal, timeoutMs = 30000 } = {}) {
- const opts = { method, credentials: "include", headers: { ...headers } };
- const signals = [signal, timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : null].filter(Boolean);
- if (signals.length) opts.signal = signals.length === 1 ? signals[0] : AbortSignal.any(signals);
-
- if (json !== undefined) {
- opts.headers["Content-Type"] = "application/json";
- opts.body = JSON.stringify(json);
- } else if (form !== undefined) {
- opts.body = form; // FormData or URLSearchParams — browser sets Content-Type
- }
-
- if (method !== "GET" && method !== "HEAD") {
- const csrf = getCookie("csrf_token");
- if (csrf) opts.headers["X-CSRF-Token"] = csrf;
- }
-
- const res = await fetch(GATEWAY_BASE + path, opts);
- return res;
-}
-
-async function apiJson(path, opts) {
- const res = await api(path, opts);
- let body = null;
- try { body = await res.json(); } catch (_) { /* no body */ }
- if (!res.ok) {
- const msg = body?.detail?.message || body?.detail || res.statusText;
- const error = new Error(typeof msg === "string" ? msg : JSON.stringify(msg));
- error.status = res.status;
- error.code = body?.detail?.code;
- throw error;
- }
- return body;
-}
+const { createApiClient, decodeResponse } = typeof require === "function" ? require("./api-client.js") : globalThis.JobScoutApi;
+const { TrackerPager } = typeof require === "function" ? require("./tracker-state.js") : globalThis.JobScoutTracker;
+const requestApi = createApiClient({ base: GATEWAY_BASE, fetcher: (...args) => fetch(...args), cookie: () => document.cookie });
+async function api(path, options) { return requestApi(path, options); }
+async function apiJson(path, options) { return decodeResponse(await api(path, options)); }
function show(view) {
for (const v of ["authView", "chatView"]) {
@@ -335,6 +192,9 @@ function setupAuthForm() {
lastBaseContext = null;
savedMatchCandidates = { threadId: null, candidates: [] };
trackerRows = [];
+ trackerPager.reset();
+ trackerStageFilter = null;
+ trackerPageLoading = false;
$("trackerLoading")?.classList.add("hidden");
$("trackerPanel")?.setAttribute("aria-busy", "false");
$("trackerUnreadCount").textContent = "0";
@@ -376,6 +236,8 @@ let chatTimerHandle = null;
let pendingFile = null;
let currentMode = "tracker";
let trackerRows = [];
+const trackerPager = new TrackerPager();
+let trackerPageLoading = false;
let trackerBusy = false;
let trackerStages = [];
let composerBusy = false;
@@ -861,7 +723,7 @@ function setMode(mode, { restoreThread = true } = {}) {
conversationOpportunityId = selectedOpportunityId;
if (restore) restoreOpportunityThread();
}
- if ($("chatStatus")) $("chatStatus").textContent = currentMode === "tracker" ? `${trackerRows.length} 条投递记录` : chatStatusText;
+ if ($("chatStatus")) $("chatStatus").textContent = currentMode === "tracker" ? `${trackerPager.data?.total ?? trackerRows.length} 条投递记录` : chatStatusText;
}
async function loadLarkStatus() {
@@ -1012,7 +874,7 @@ function showTrackerBatchResult(rows) {
if (!dialog) return;
const latest = [...rows].reverse().find((row) => row.checked_at) || rows[0];
if (!latest) return;
- notifyTrackerResult(latest, `刷新完成,当前共 ${rows.length} 条投递记录;最新结果已更新到表格。`);
+ notifyTrackerResult(latest, `刷新完成,当前共 ${trackerPager.data?.total ?? rows.length} 条投递记录;最新结果已更新到表格。`);
}
function setTrackerBusy(busy) {
@@ -1038,38 +900,45 @@ function renderTrackerRows() {
const body = $("trackerTableBody");
if (!body) return;
body.replaceChildren();
- const summary = trackerSummary(trackerRows);
+ const summary = trackerPager.data?.summary || trackerSummary(trackerRows);
for (const [key, id] of Object.entries({ total: "trackerTotalCount", active: "trackerActiveCount", review: "trackerReviewCount", offers: "trackerOfferCount" })) {
if ($(id)) $(id).textContent = String(summary[key]);
}
- const options = trackerStageFilterOptions(trackerRows, trackerStages);
+ const counts = trackerPager.data?.stages || [];
+ const options = [...counts].sort((a, b) => { const order = label => trackerStages.includes(label) ? trackerStages.indexOf(label) : trackerStages.length; return order(a.label) - order(b.label); });
if (trackerStageFilter !== null && !options.some((option) => option.label === trackerStageFilter)) {
options.push({ label: trackerStageFilter, count: 0 });
}
const visibleRows = filterTrackerRows(trackerRows, trackerStageFilter);
- const hasRows = trackerRows.length > 0;
+ const hasRows = summary.total > 0;
$("trackerEmpty")?.classList.toggle("hidden", hasRows);
$("trackerNoResults")?.classList.toggle("hidden", !hasRows || visibleRows.length > 0);
$("trackerTableWrap")?.classList.toggle("hidden", visibleRows.length === 0);
$("trackerFilterBar")?.classList.toggle("hidden", !hasRows);
- if ($("trackerFilterSummary")) $("trackerFilterSummary").textContent = `显示 ${visibleRows.length} / ${trackerRows.length} 条`;
+ if ($("trackerFilterSummary")) $("trackerFilterSummary").textContent = `本页 ${visibleRows.length} 条 / 共 ${trackerPager.data?.filtered_total ?? 0} 条`;
const tags = $("trackerFilterTags");
if (tags) {
tags.replaceChildren();
- for (const option of [{ label: "全部", count: trackerRows.length, value: null }, ...options.map((item) => ({ ...item, value: item.label }))]) {
+ for (const option of [{ label: "全部", count: summary.total, value: null }, ...options.map((item) => ({ ...item, value: item.label }))]) {
const tag = trackerElement("button", "tracker-filter-tag");
tag.type = "button";
tag.setAttribute("aria-pressed", String(trackerStageFilter === option.value));
tag.append(trackerElement("span", "", option.label), trackerElement("span", "tracker-filter-count", String(option.count)));
- tag.addEventListener("click", () => {
+ tag.addEventListener("click", async () => {
trackerStageFilter = option.value;
- renderTrackerRows();
+ trackerPager.filter(option.value);
+ await loadTrackerApplications();
tags.querySelector('[aria-pressed="true"]')?.focus();
});
tags.append(tag);
}
}
+ $("trackerPagination")?.classList.toggle("hidden", !hasRows);
+ if ($("trackerPageLabel")) $("trackerPageLabel").textContent = `第 ${trackerPager.page} 页 · 每页 ${trackerPager.limit} 条`;
+ if ($("trackerPreviousPage")) $("trackerPreviousPage").disabled = trackerPageLoading || trackerPager.page === 1;
+ if ($("trackerNextPage")) $("trackerNextPage").disabled = trackerPageLoading || !trackerPager.data?.has_more;
+
for (const storedRow of visibleRows) {
const row = trackerDisplayRow(storedRow);
const presentation = trackerRowPresentation(row);
@@ -1161,7 +1030,7 @@ function renderTrackerRows() {
removeButton.type = "button";
removeButton.addEventListener("click", async () => {
if (!confirm(`确定删除 ${row.company} - ${row.role}?`)) return;
- try { await apiJson(`/api/jobscout/tracker/applications/${row.id}`, { method: "DELETE" }); trackerRows = trackerRows.filter((item) => item.id !== row.id); renderTrackerRows(); await loadOpportunities(); }
+ try { await apiJson(`/api/jobscout/tracker/applications/${row.id}`, { method: "DELETE" }); await loadTrackerApplications(); await loadOpportunities(); }
catch (error) { showTrackerError(error.message || String(error)); }
});
actionCell.append(removeButton);
@@ -1184,30 +1053,37 @@ async function patchTrackerRow(id, changes) {
try {
const updated = await apiJson(`/api/jobscout/tracker/applications/${id}`, { method: "PATCH", json: changes });
trackerRows = trackerRows.map((row) => row.id === id ? updated : row);
- renderTrackerRows();
+ await loadTrackerApplications();
} catch (error) { showTrackerError(error.message || String(error)); await loadTrackerApplications(); }
}
-async function loadTrackerApplications() {
+async function loadTrackerApplications({ signal } = {}) {
const current = beginViewRequest("tracker");
+ trackerPageLoading = true;
+ renderTrackerRows();
showTrackerError();
$("trackerLoading")?.classList.remove("hidden");
$("trackerPanel")?.setAttribute("aria-busy", "true");
try {
- const rows = await apiJson("/api/jobscout/tracker/applications");
+ const data = await apiJson(trackerPager.url(), { signal });
if (!current()) return;
- trackerRows = Array.isArray(rows) ? rows : [];
+ trackerRows = trackerPager.accept(data);
+ if (!trackerRows.length && trackerPager.previous()) return await loadTrackerApplications({ signal });
if (!trackerAddFormInitialized) {
- setTrackerAddFormOpen(trackerRows.length === 0);
+ setTrackerAddFormOpen(trackerPager.data.total === 0);
trackerAddFormInitialized = true;
}
renderTrackerRows();
- if (currentMode === "tracker" && $("chatStatus")) $("chatStatus").textContent = `${trackerRows.length} 条投递记录`;
+ if (currentMode === "tracker" && $("chatStatus")) $("chatStatus").textContent = `${trackerPager.data?.total ?? trackerRows.length} 条投递记录`;
loadTrackerNotifications().catch(() => {});
} catch (error) {
- if (current()) showTrackerError(error.message || String(error));
+ if (current()) {
+ trackerPager.reject(); trackerStageFilter = trackerPager.stage;
+ showTrackerError(error.message || String(error));
+ }
} finally {
if (current()) {
+ trackerPageLoading = false; renderTrackerRows();
$("trackerLoading")?.classList.add("hidden");
$("trackerPanel")?.setAttribute("aria-busy", "false");
}
@@ -1381,14 +1257,14 @@ async function refreshTrackerRow(applicationId) {
setTrackerBusy(true);
showTrackerError();
const current = trackerRows.find((row) => row.id === applicationId);
- const knownIds = new Set(trackerRows.map((row) => row.id));
+ const knownCount = trackerPager.data?.total || 0;
setTrackerProgress(`正在检查 ${current?.company || "该岗位"};如需登录会弹出浏览器窗口`, 0, 1, true);
try {
const outcome = await apiJson(`/api/jobscout/tracker/applications/${applicationId}/refresh`, { method: "POST", signal: job.controller.signal, timeoutMs: 600000 });
- const rows = await apiJson("/api/jobscout/tracker/applications", { signal: job.controller.signal });
if (!jobIsCurrent(job) || job.controller.signal.aborted) return null;
- trackerRows = rows;
- const added = trackerRows.filter((row) => !knownIds.has(row.id)).length;
+ await loadTrackerApplications({ signal: job.controller.signal });
+ if (!jobIsCurrent(job) || job.controller.signal.aborted) return null;
+ const added = Math.max(0, (trackerPager.data?.total || 0) - knownCount);
renderTrackerRows();
loadOpportunities().catch((error) => console.error("loadOpportunities failed", error));
setTrackerProgress(outcome.skipped ? "已跳过重复或失效的检查" : "检查完成", 1, 1, true);
@@ -1512,7 +1388,9 @@ function setupTracker() {
setTrackerAddFormOpen(!trackerAddFormOpen);
if (trackerAddFormOpen) $("trackerAddForm")?.elements.company.focus();
});
- $("trackerClearFilter")?.addEventListener("click", () => { trackerStageFilter = null; renderTrackerRows(); });
+ $("trackerClearFilter")?.addEventListener("click", () => { trackerStageFilter = null; trackerPager.filter(null); loadTrackerApplications(); });
+ $("trackerNextPage")?.addEventListener("click", () => { if (trackerPager.next()) loadTrackerApplications(); });
+ $("trackerPreviousPage")?.addEventListener("click", () => { if (trackerPager.previous()) loadTrackerApplications(); });
$("trackerRefreshAllBtn")?.addEventListener("click", refreshAllTrackerRows);
$("trackerStagesBtn")?.addEventListener("click", () => {
const panel = $("trackerStageEditor");
@@ -1530,7 +1408,7 @@ function setupTracker() {
try {
setTrackerBusy(true); showTrackerError();
const row = await apiJson("/api/jobscout/tracker/applications", { method: "POST", json: { company: data.get("company"), url: data.get("url") } });
- trackerRows = [...trackerRows.filter((item) => item.id !== row.id), row]; renderTrackerRows(); form.reset();
+ trackerPager.filter(null); trackerStageFilter = null; await loadTrackerApplications(); form.reset();
const refreshed = await refreshTrackerRow(row.id);
setTrackerAddFormOpen(false);
const opportunity = selectedOpportunity();
@@ -2004,168 +1882,6 @@ async function streamRunToText(threadId, messageText, filesMeta, onProgress, job
return extractLastVisibleAiText(lastMessages, runMode);
}
-function jobScoutRunContext(mode, contextRef = null) {
- return {
- jobscout_mode: mode === "match" ? "base_match" : "interview_prep",
- jobscout_evidence_version: 2,
- ...(mode === "match" && typeof contextRef === "string" && contextRef ? { jobscout_base_context_ref: contextRef } : {}),
- };
-}
-
-/** Canonical display text for both values streams and persisted history.
- * Backend-filtered content also feeds renderReport's print/download actions.
- * Old reports cannot be retroactively verified without their original tools. */
-function guardedMessageText(message, mode = "prep") {
- if (message?.additional_kwargs?.hide_from_ui) return "";
- if (message?.type === "tool" && message.name !== "ask_clarification") return "";
- if (message?.tool_calls?.length) return "";
- const text = contentToText(message?.content).trim();
- if (!text || message?.type === "tool" && message.name === "ask_clarification") return text;
- const stamp = message?.additional_kwargs?.jobscout_evidence;
- const route = message?.additional_kwargs?.jobscout_route;
- if (route?.version === 3 && typeof route.run_id === "string" && route.run_id &&
- typeof route.in_scope === "boolean" && Array.isArray(route.missing_fields) &&
- (!route.in_scope || route.missing_fields.length > 0)) return text;
- if (stamp?.version === 2 && stamp.mode === (mode === "match" ? "base_match" : "interview_prep") &&
- typeof stamp.run_id === "string" && stamp.run_id &&
- Number.isInteger(stamp.accepted_count) && stamp.accepted_count >= 0 &&
- Number.isInteger(stamp.removed_count) && stamp.removed_count >= 0) return text;
- return "这份内容尚无本次运行的结构化证据与来源校验记录,暂不展示或导出。旧报告需要重新生成;新报告请先启用 JobScout 来源校验中间件。";
-}
-
-function contentToText(content) {
- if (typeof content === "string") return content;
- if (Array.isArray(content)) {
- return content
- .map((part) => (typeof part === "string" ? part : part?.text || ""))
- .filter(Boolean)
- .join("\n");
- }
- return "";
-}
-
-function extractLastVisibleAiText(messages, mode = "prep") {
- // `ask_clarification` is `return_direct=True`: LangGraph ends the run with
- // its ToolMessage (type "tool") as the final state, and the model's own
- // preceding AIMessage carries the tool *call*, not the question text the
- // user is meant to see. Only checking type "ai" here silently swallowed
- // every clarification question — a real bug found via a live user report,
- // not code review (Gateway logs proved the server side worked fine; the
- // Gateway's actual response just wasn't the shape this function assumed).
- for (let i = messages.length - 1; i >= 0; i--) {
- const m = messages[i];
- if (m?.type === "human") break;
- if (m?.additional_kwargs?.hide_from_ui) continue;
- if (m?.type === "ai" || m?.type === "tool") {
- const text = guardedMessageText(m, mode);
- if (text) return text;
- }
- }
- return "";
-}
-
-/** A real report has these as standalone section-title lines. The preferred
- * shape is a Markdown heading, but live models may emit a numbered outline
- * (`1) 公司速览`, `2) 岗位拆解(...)`) while still returning the complete
- * document. A prose mention must NOT count, or the UI renders print/download
- * buttons for an offer to create a report rather than the report itself. */
-function looksLikeReport(text) {
- const sectionPrefix = "(?:#{1,3}\\s+|\\d+\\s*[)).、]\\s*)?";
- const sectionSuffix = "(?:[((][^\\r\\n]*[))])?\\s*$";
- const hitCount = (markers) => markers.filter((m) =>
- new RegExp(`^${sectionPrefix}${m}${sectionSuffix}`, "m").test(text)
- ).length;
- return hitCount(["公司速览", "岗位拆解", "面试题预测"]) >= 2 ||
- hitCount(["候选人画像", "推荐岗位", "匹配依据", "风险与数据边界"]) >= 3;
-}
-
-/** Keep the JobScout document contract stable when a model appends generic
- * coaching chapters of its own. This is deliberately conservative: only
- * known top-level drift is removed, so numbered questions and preparation
- * steps inside an allowed section are never mistaken for new chapters. */
-function sanitizeJobScoutReportMarkdown(markdown) {
- const source = String(markdown || "");
- const matchingMode = source.includes("简历 × 飞书岗位匹配报告") ||
- ["候选人画像", "推荐岗位", "匹配依据", "风险与数据边界"].filter((s) => source.includes(s)).length >= 3;
- const allowedSections = matchingMode
- ? ["候选人画像", "推荐岗位", "匹配依据", "风险与数据边界"]
- : ["公司速览", "岗位拆解", "面试题预测", "差距分析", "证据边界与后续建议"];
- const unwantedSections = [
- "使用说明",
- "一周上岸计划",
- "上岸计划",
- "冲刺计划",
- "话术模板",
- "专项准备资料",
- "清单与打卡",
- "执行凭据",
- "工具收据",
- "sources",
- ];
- const startsWithAny = (value, candidates) => {
- const normalized = value.trim().toLowerCase();
- return candidates.some((candidate) => normalized.startsWith(candidate.toLowerCase()));
- };
-
- let keep = true;
- const keptLines = [];
- for (const line of source.split(/\r?\n/)) {
- const trimmed = line.trim();
- const numbered = trimmed.match(/^\d+\s*[)).、]\s*(.+)$/);
- const levelTwo = trimmed.match(/^##(?!#)\s+(.+)$/);
- const bareStop = /^(?:执行凭据(?:(工具收据))?|工具收据|sources)$/i.test(trimmed);
- const candidate = numbered?.[1] || levelTwo?.[1] || (bareStop ? trimmed : "");
-
- if (candidate) {
- if (startsWithAny(candidate, allowedSections)) {
- keep = true;
- } else if (levelTwo || bareStop || startsWithAny(candidate, unwantedSections)) {
- keep = false;
- }
- }
-
- if (keep) keptLines.push(line);
- }
-
- return keptLines.join("\n").trimEnd() + "\n";
-}
-
-/** Normalize common model formatting drift for the rendered/printed view.
- * The sanitized Markdown is also used for download. */
-function normalizeReportMarkdownForRender(markdown) {
- let seenNonEmpty = false;
- return markdown
- .split(/\r?\n/)
- .map((line) => {
- const trimmed = line.trim();
- if (!trimmed) return line;
-
- if (!seenNonEmpty) {
- seenNonEmpty = true;
- if (!trimmed.startsWith("#") && (trimmed.includes("面试准备包") || trimmed.includes("岗位匹配报告"))) {
- return `# ${trimmed}`;
- }
- }
-
- const numberedSection = trimmed.match(
- /^\d+\s*[)).、]\s*(公司速览|岗位拆解|面试题预测|差距分析|候选人画像|推荐岗位|匹配依据|风险与数据边界)(.*)$/
- );
- if (numberedSection) {
- return `## ${numberedSection[1]}${numberedSection[2]}`;
- }
-
- if (
- !trimmed.startsWith("#") &&
- /^(?:业务与产品|近期动态|融资\s*[\//]\s*规模(?:(.*))?|公司技术栈核对(?:(.*))?|技术\s*[\//]\s*岗位题|行为题|证据清单(?:(.*))?)$/.test(trimmed)
- ) {
- return `### ${trimmed}`;
- }
-
- return line;
- })
- .join("\n");
-}
-
/** Run one turn (any user message: the opening ask or a mid-conversation
* reply) and either post the assistant's reply as a bubble — conversation
* continues via the composer, same as any chat turn — or, once the
@@ -2587,119 +2303,6 @@ function renderHistoryMessages(messages) {
// report template uses: #/##/### headings, **bold**, [text](url) links,
// bullet/numbered lists, "|" tables, "> " blockquotes, and paragraphs.
-function escapeHtml(s) {
- return s.replace(/&/g, "&").replace(//g, ">");
-}
-
-function inlineMd(s) {
- // Code-rendered evidence escapes Markdown punctuation. Preserve those
- // characters as text before interpreting actual formatting and links.
- let out = escapeHtml(s)
- .replace(/\\([\\`*_{}\[\]()#+.!|])/g, (_, char) => "" + char.charCodeAt(0) + ";")
- .replace(/\\(<|>)/g, "$1");
- out = out.replace(/\[([^\]]+)\]\(<(https?:\/\/[^\s]+?)>\)/g, '$1');
- out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1');
- out = out.replace(/\*\*([^*]+)\*\*/g, "$1");
- out = out.replace(/`([^`]+)`/g, "$1");
- return out;
-}
-
-function markdownTableCells(row) {
- const cells = [""];
- const text = row.replace(/^\||\|$/g, "");
- for (let i = 0; i < text.length; i++) {
- if (text[i] === "\\" && i + 1 < text.length) {
- cells[cells.length - 1] += text[i] + text[++i];
- } else if (text[i] === "|") cells.push("");
- else cells[cells.length - 1] += text[i];
- }
- return cells.map(cell => cell.trim());
-}
-
-function markdownToHtml(md) {
- const lines = md.replace(/\r\n/g, "\n").split("\n");
- let html = "";
- let i = 0;
- let inList = null; // 'ul' | 'ol' | null
-
- function closeList() {
- if (inList) { html += `${inList}>`; inList = null; }
- }
-
- while (i < lines.length) {
- const line = lines[i];
-
- if (/^\s*$/.test(line)) { closeList(); i++; continue; }
-
- const heading = line.match(/^(#{1,4})\s+(.*)$/);
- if (heading) {
- closeList();
- const level = heading[1].length;
- html += `
${inlineMd(quoteLines.join(" "))}`; - continue; - } - - if (/^\s*\|.*\|\s*$/.test(line)) { - closeList(); - const rows = []; - while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { - rows.push(lines[i].trim()); - i++; - } - if (rows.length >= 2 && /^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?$/.test(rows[1])) { - const headCells = markdownTableCells(rows[0]); - html += "
| ${inlineMd(c)} | `).join("") + "
|---|
| ${inlineMd(c)} | `).join("") + "
${inlineMd(r)}
`; }); - } - continue; - } - - const bullet = line.match(/^\s*[-*]\s+(.*)$/); - if (bullet) { - if (inList !== "ul") { closeList(); html += "${inlineMd(paraLines.join(" "))}
`; - } - closeList(); - return html; -} - // -------------------------------------------------------------- bootstrap -- // Guarded so this file can also be loaded under Node (no `document`) to unit // test the pure functions above (markdownToHtml, extractLastVisibleAiText, diff --git a/jobscout-web/core.js b/jobscout-web/core.js new file mode 100644 index 00000000..7698fb9d --- /dev/null +++ b/jobscout-web/core.js @@ -0,0 +1,565 @@ +// Pure display, evidence and stream helpers. No DOM or application state. +(function (root, factory) { + const value = factory(); + if (typeof module !== "undefined") module.exports = value; + else root.JobScoutCore = value; +})(globalThis, function () { + function resolveDeploymentConfig(config = {}, location = {}) { + const localSplit = + location.protocol === "http:" && + location.port === "5500" && + ["localhost", "127.0.0.1", "[::1]"].includes(location.hostname); + const localHost = location.hostname || "localhost"; + const gatewayBase = + config.gatewayBase ?? (localSplit ? `http://${localHost}:8001` : ""); + const capabilityCenterUrl = + config.capabilityCenterUrl ?? + (localSplit + ? `http://${localHost}:3000/workspace/capabilities` + : "/workspace/capabilities"); + function validateUrl(value, allowPath) { + if (typeof value !== "string") throw new Error("部署地址必须是字符串"); + if (allowPath && /^\/(?!\/)/.test(value) && !value.includes("\\")) + return value; + if (!value && !allowPath) return ""; + const parsed = new URL(value); + if ( + !["http:", "https:"].includes(parsed.protocol) || + parsed.username || + parsed.password || + (!allowPath && + (parsed.pathname !== "/" || parsed.search || parsed.hash)) + ) { + throw new Error( + "部署地址必须是有效的 HTTP(S) 地址,且不能包含账号密码", + ); + } + return allowPath ? parsed.href : parsed.origin; + } + return { + gatewayBase: validateUrl(gatewayBase, false), + capabilityCenterUrl: validateUrl(capabilityCenterUrl, true), + }; + } + + const TERMINAL_TRACKER_STATUSES = new Set(["Offer", "未通过", "流程终止"]); + const STOPPED_TRACKER_STATUSES = new Set(["未通过", "流程终止"]); + + function isTerminalTrackerStatus(status) { + return TERMINAL_TRACKER_STATUSES.has(status); + } + + function trackerDisplayRow(row) { + const source = row?.source_summary; + if (!source || source.conflict || source.source !== "email") return row; + return { + ...row, + portal_status: row.status, + portal_evidence: row.evidence, + status: source.status, + stage: row.stage_manual ? row.stage : source.status, + raw_status: source.status, + evidence: source.evidence, + checked_at: source.received_at, + changed_at: source.received_at, + check_result: "成功", + confidence: 1, + changed: false, + }; + } + + function trackerRowPresentation(row) { + const terminal = + Boolean(row?.terminal) || isTerminalTrackerStatus(row?.status); + const needsReview = + Boolean(row?.checked_at) && Number(row?.confidence) < 0.7; + const changed = Boolean(row?.changed && row?.previous_status); + const stopped = + STOPPED_TRACKER_STATUSES.has(row?.status) || + STOPPED_TRACKER_STATUSES.has(row?.stage); + const unknown = + needsReview || + !row?.status || + row.status === "未知" || + (row.check_result && row.check_result !== "成功"); + return { + terminal, + canRefresh: !terminal, + needsReview, + changeText: changed ? `${row.previous_status} → ${row.status}` : "", + tone: changed ? "changed" : needsReview ? "review" : "normal", + statusTone: stopped ? "stopped" : unknown ? "unknown" : "active", + }; + } + + function trackerSummary(rows) { + const summary = { total: rows.length, active: 0, review: 0, offers: 0 }; + for (const stored of rows) { + const row = trackerDisplayRow(stored); + const presentation = trackerRowPresentation(row); + const review = + row.source_summary?.conflict || + presentation.needsReview || + !row.status || + row.status === "未知" || + (row.check_result && row.check_result !== "成功"); + if (review) summary.review += 1; + else if (row.status === "Offer") summary.offers += 1; + else if (!presentation.terminal) summary.active += 1; + } + return summary; + } + + function trackerStageWaitText(row, now = new Date()) { + if (!row?.status || row.status === "未知") return "阶段待确认"; + if (row.terminal || isTerminalTrackerStatus(row.status)) + return "流程已结束"; + if (row.stage_manual) return "手动环节,起点待确认"; + if (!row.changed_at) return "等待首次检查"; + const started = new Date(row.changed_at); + const elapsed = now.getTime() - started.getTime(); + if (!Number.isFinite(elapsed) || elapsed < 0) return "起点时间待确认"; + const days = Math.floor(elapsed / 86400000); + return days < 1 + ? "不足 1 天(自首次识别)" + : `至少 ${days} 天(自首次识别)`; + } + + function trackerStageLabel(row) { + row = trackerDisplayRow(row); + return row?.stage || row?.status || "未分类"; + } + + function trackerStageFilterOptions(rows, stages = []) { + const counts = new Map(); + for (const row of rows) { + const label = trackerStageLabel(row); + counts.set(label, (counts.get(label) || 0) + 1); + } + const ordered = [...new Set(stages)].filter((label) => counts.has(label)); + for (const label of counts.keys()) { + if (!ordered.includes(label)) ordered.push(label); + } + return ordered.map((label) => ({ label, count: counts.get(label) })); + } + + function filterTrackerRows(rows, stage) { + return stage === null + ? rows + : rows.filter((row) => trackerStageLabel(row) === stage); + } + + function parseSseFrames(buffer) { + const normalized = String(buffer || "").replace(/\r\n/g, "\n"); + const frames = normalized.split("\n\n"); + const remainder = frames.pop() || ""; + const events = []; + for (const frame of frames) { + const data = frame + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()) + .join("\n"); + if (data) events.push(JSON.parse(data)); + } + return { events, remainder }; + } + + // ---------------------------------------------------------------- helpers -- + + function jobScoutRunContext(mode, contextRef = null) { + return { + jobscout_mode: mode === "match" ? "base_match" : "interview_prep", + jobscout_evidence_version: 2, + ...(mode === "match" && typeof contextRef === "string" && contextRef + ? { jobscout_base_context_ref: contextRef } + : {}), + }; + } + + /** Canonical display text for both values streams and persisted history. + * Backend-filtered content also feeds renderReport's print/download actions. + * Old reports cannot be retroactively verified without their original tools. */ + function guardedMessageText(message, mode = "prep") { + if (message?.additional_kwargs?.hide_from_ui) return ""; + if (message?.type === "tool" && message.name !== "ask_clarification") + return ""; + if (message?.tool_calls?.length) return ""; + const text = contentToText(message?.content).trim(); + if ( + !text || + (message?.type === "tool" && message.name === "ask_clarification") + ) + return text; + const stamp = message?.additional_kwargs?.jobscout_evidence; + const route = message?.additional_kwargs?.jobscout_route; + if ( + route?.version === 3 && + typeof route.run_id === "string" && + route.run_id && + typeof route.in_scope === "boolean" && + Array.isArray(route.missing_fields) && + (!route.in_scope || route.missing_fields.length > 0) + ) + return text; + if ( + stamp?.version === 2 && + stamp.mode === (mode === "match" ? "base_match" : "interview_prep") && + typeof stamp.run_id === "string" && + stamp.run_id && + Number.isInteger(stamp.accepted_count) && + stamp.accepted_count >= 0 && + Number.isInteger(stamp.removed_count) && + stamp.removed_count >= 0 + ) + return text; + return "这份内容尚无本次运行的结构化证据与来源校验记录,暂不展示或导出。旧报告需要重新生成;新报告请先启用 JobScout 来源校验中间件。"; + } + + function contentToText(content) { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((part) => (typeof part === "string" ? part : part?.text || "")) + .filter(Boolean) + .join("\n"); + } + return ""; + } + + function extractLastVisibleAiText(messages, mode = "prep") { + // `ask_clarification` is `return_direct=True`: LangGraph ends the run with + // its ToolMessage (type "tool") as the final state, and the model's own + // preceding AIMessage carries the tool *call*, not the question text the + // user is meant to see. Only checking type "ai" here silently swallowed + // every clarification question — a real bug found via a live user report, + // not code review (Gateway logs proved the server side worked fine; the + // Gateway's actual response just wasn't the shape this function assumed). + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m?.type === "human") break; + if (m?.additional_kwargs?.hide_from_ui) continue; + if (m?.type === "ai" || m?.type === "tool") { + const text = guardedMessageText(m, mode); + if (text) return text; + } + } + return ""; + } + + /** A real report has these as standalone section-title lines. The preferred + * shape is a Markdown heading, but live models may emit a numbered outline + * (`1) 公司速览`, `2) 岗位拆解(...)`) while still returning the complete + * document. A prose mention must NOT count, or the UI renders print/download + * buttons for an offer to create a report rather than the report itself. */ + function looksLikeReport(text) { + const sectionPrefix = "(?:#{1,3}\\s+|\\d+\\s*[)).、]\\s*)?"; + const sectionSuffix = "(?:[((][^\\r\\n]*[))])?\\s*$"; + const hitCount = (markers) => + markers.filter((m) => + new RegExp(`^${sectionPrefix}${m}${sectionSuffix}`, "m").test(text), + ).length; + return ( + hitCount(["公司速览", "岗位拆解", "面试题预测"]) >= 2 || + hitCount(["候选人画像", "推荐岗位", "匹配依据", "风险与数据边界"]) >= 3 + ); + } + + /** Keep the JobScout document contract stable when a model appends generic + * coaching chapters of its own. This is deliberately conservative: only + * known top-level drift is removed, so numbered questions and preparation + * steps inside an allowed section are never mistaken for new chapters. */ + function sanitizeJobScoutReportMarkdown(markdown) { + const source = String(markdown || ""); + const matchingMode = + source.includes("简历 × 飞书岗位匹配报告") || + ["候选人画像", "推荐岗位", "匹配依据", "风险与数据边界"].filter((s) => + source.includes(s), + ).length >= 3; + const allowedSections = matchingMode + ? ["候选人画像", "推荐岗位", "匹配依据", "风险与数据边界"] + : [ + "公司速览", + "岗位拆解", + "面试题预测", + "差距分析", + "证据边界与后续建议", + ]; + const unwantedSections = [ + "使用说明", + "一周上岸计划", + "上岸计划", + "冲刺计划", + "话术模板", + "专项准备资料", + "清单与打卡", + "执行凭据", + "工具收据", + "sources", + ]; + const startsWithAny = (value, candidates) => { + const normalized = value.trim().toLowerCase(); + return candidates.some((candidate) => + normalized.startsWith(candidate.toLowerCase()), + ); + }; + + let keep = true; + const keptLines = []; + for (const line of source.split(/\r?\n/)) { + const trimmed = line.trim(); + const numbered = trimmed.match(/^\d+\s*[)).、]\s*(.+)$/); + const levelTwo = trimmed.match(/^##(?!#)\s+(.+)$/); + const bareStop = /^(?:执行凭据(?:(工具收据))?|工具收据|sources)$/i.test( + trimmed, + ); + const candidate = + numbered?.[1] || levelTwo?.[1] || (bareStop ? trimmed : ""); + + if (candidate) { + if (startsWithAny(candidate, allowedSections)) { + keep = true; + } else if ( + levelTwo || + bareStop || + startsWithAny(candidate, unwantedSections) + ) { + keep = false; + } + } + + if (keep) keptLines.push(line); + } + + return keptLines.join("\n").trimEnd() + "\n"; + } + + /** Normalize common model formatting drift for the rendered/printed view. + * The sanitized Markdown is also used for download. */ + function normalizeReportMarkdownForRender(markdown) { + let seenNonEmpty = false; + return markdown + .split(/\r?\n/) + .map((line) => { + const trimmed = line.trim(); + if (!trimmed) return line; + + if (!seenNonEmpty) { + seenNonEmpty = true; + if ( + !trimmed.startsWith("#") && + (trimmed.includes("面试准备包") || trimmed.includes("岗位匹配报告")) + ) { + return `# ${trimmed}`; + } + } + + const numberedSection = trimmed.match( + /^\d+\s*[)).、]\s*(公司速览|岗位拆解|面试题预测|差距分析|候选人画像|推荐岗位|匹配依据|风险与数据边界)(.*)$/, + ); + if (numberedSection) { + return `## ${numberedSection[1]}${numberedSection[2]}`; + } + + if ( + !trimmed.startsWith("#") && + /^(?:业务与产品|近期动态|融资\s*[\//]\s*规模(?:(.*))?|公司技术栈核对(?:(.*))?|技术\s*[\//]\s*岗位题|行为题|证据清单(?:(.*))?)$/.test( + trimmed, + ) + ) { + return `### ${trimmed}`; + } + + return line; + }) + .join("\n"); + } + + function escapeHtml(s) { + return s.replace(/&/g, "&").replace(//g, ">"); + } + + function inlineMd(s) { + // Code-rendered evidence escapes Markdown punctuation. Preserve those + // characters as text before interpreting actual formatting and links. + let out = escapeHtml(s) + .replace( + /\\([\\`*_{}\[\]()#+.!|])/g, + (_, char) => "" + char.charCodeAt(0) + ";", + ) + .replace(/\\(<|>)/g, "$1"); + out = out.replace( + /\[([^\]]+)\]\(<(https?:\/\/[^\s]+?)>\)/g, + '$1', + ); + out = out.replace( + /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, + '$1', + ); + out = out.replace(/\*\*([^*]+)\*\*/g, "$1"); + out = out.replace(/`([^`]+)`/g, "$1");
+ return out;
+ }
+
+ function markdownTableCells(row) {
+ const cells = [""];
+ const text = row.replace(/^\||\|$/g, "");
+ for (let i = 0; i < text.length; i++) {
+ if (text[i] === "\\" && i + 1 < text.length) {
+ cells[cells.length - 1] += text[i] + text[++i];
+ } else if (text[i] === "|") cells.push("");
+ else cells[cells.length - 1] += text[i];
+ }
+ return cells.map((cell) => cell.trim());
+ }
+
+ function markdownToHtml(md) {
+ const lines = md.replace(/\r\n/g, "\n").split("\n");
+ let html = "";
+ let i = 0;
+ let inList = null; // 'ul' | 'ol' | null
+
+ function closeList() {
+ if (inList) {
+ html += `${inList}>`;
+ inList = null;
+ }
+ }
+
+ while (i < lines.length) {
+ const line = lines[i];
+
+ if (/^\s*$/.test(line)) {
+ closeList();
+ i++;
+ continue;
+ }
+
+ const heading = line.match(/^(#{1,4})\s+(.*)$/);
+ if (heading) {
+ closeList();
+ const level = heading[1].length;
+ html += `${inlineMd(quoteLines.join(" "))}`; + continue; + } + + if (/^\s*\|.*\|\s*$/.test(line)) { + closeList(); + const rows = []; + while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { + rows.push(lines[i].trim()); + i++; + } + if ( + rows.length >= 2 && + /^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?$/.test(rows[1]) + ) { + const headCells = markdownTableCells(rows[0]); + html += + "
| ${inlineMd(c)} | `).join("") + + "
|---|
| ${inlineMd(c)} | `).join("") + + "
${inlineMd(r)}
`; + }); + } + continue; + } + + const bullet = line.match(/^\s*[-*]\s+(.*)$/); + if (bullet) { + if (inList !== "ul") { + closeList(); + html += "${inlineMd(paraLines.join(" "))}
`; + } + closeList(); + return html; + } + + return { + resolveDeploymentConfig, + isTerminalTrackerStatus, + trackerDisplayRow, + trackerRowPresentation, + trackerSummary, + trackerStageWaitText, + trackerStageLabel, + trackerStageFilterOptions, + filterTrackerRows, + parseSseFrames, + jobScoutRunContext, + guardedMessageText, + contentToText, + extractLastVisibleAiText, + looksLikeReport, + sanitizeJobScoutReportMarkdown, + normalizeReportMarkdownForRender, + escapeHtml, + inlineMd, + markdownTableCells, + markdownToHtml, + }; +}); diff --git a/jobscout-web/index.html b/jobscout-web/index.html index 0eb91752..5456d090 100644 --- a/jobscout-web/index.html +++ b/jobscout-web/index.html @@ -367,6 +367,11 @@