Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
d15e4fd
feat(api-channel): thick-contract fetch() + encrypted credential stor…
2233admin Jul 1, 2026
8d0c6ad
fix(api-channel): forward configured timeout on the shared ctx.http c…
2233admin Jul 1, 2026
7de0191
fix(api-channel): fetch() catches generic exceptions like collect() does
2233admin Jul 1, 2026
2beefe8
refactor(auth): unify bearer/api_key/basic header building into one h…
2233admin Jul 1, 2026
79fc2e4
fix(auth): recover from a concurrent-store race on the unique constraint
2233admin Jul 1, 2026
91edcc0
fix(schemas): CredentialCreate.key_name max_length matches DB column
2233admin Jul 1, 2026
96511d9
fix(sources): cascade-delete credentials when a source is deleted
2233admin Jul 1, 2026
93c67db
perf(pipeline): skip cursor-DB load + rate-limited-client build for u…
2233admin Jul 1, 2026
eafef17
refactor(api-channel): collect() delegates to fetch(), killing ~50 du…
2233admin Jul 1, 2026
1ee0cb0
fix(frontend): CredentialField name attribute no longer collides on C…
2233admin Jul 1, 2026
5e0a344
fix(frontend): CredentialField distinguishes 'not stored' from 'statu…
2233admin Jul 1, 2026
f5c3f76
fix(pipeline): reserve dunder-prefixed keys for cursor-commit bookkee…
2233admin Jul 1, 2026
b8b4e57
docs(pipeline): document run_channel's last-page-wins metadata merge …
2233admin Jul 1, 2026
75db78a
fix(pipeline): log discarded items on a mid-pagination fetch failure
2233admin Jul 1, 2026
0e93ce8
chore: add .gitattributes to stop CRLF/LF diff churn
2233admin Jul 1, 2026
2e58cc3
feat(reliability): instrument swallowed exception types + fix rss tim…
2233admin Jul 1, 2026
e0c196f
feat(reliability): error taxonomy + idempotency hardening for retries…
2233admin Jul 1, 2026
30ac9ae
feat(reliability): activate real celery retry for retryable failures …
2233admin Jul 1, 2026
77b4afb
feat(reliability): wire redbeat as celery beat backend, live schedule…
2233admin Jul 1, 2026
bbfd5b9
feat(reliability): migrate web_scraper to fetch(); evaluate opencli/c…
2233admin Jul 1, 2026
a86c216
feat(reliability): real per-channel health probes, two-tier liveness/…
2233admin Jul 1, 2026
41a4988
feat(skills): auto-propose redistill after N straight fails, dismiss/…
2233admin Jul 1, 2026
50a7e5e
feat(skills): record->distill workflow, opencli-skill CLI, and dock p…
2233admin Jul 1, 2026
bec3a0c
feat(chat): configure AI enrichment provider via dock chat tool
2233admin Jul 1, 2026
ed1ca2a
test(chat): cover list_providers/update_provider read, propose, confirm
2233admin Jul 1, 2026
b1245c5
test(skills): verify execute-loop live E2E on real Chrome+Edge, add c…
2233admin Jul 1, 2026
8755872
feat(auth): CookieCloud sync -> domain-keyed cookie_jar -> api/web_sc…
2233admin Jul 1, 2026
0534450
feat(channels): Crawl4AI channel for JS-rendered pages + built-in ant…
2233admin Jul 1, 2026
b081fee
feat(sources): RSS onboarding -- feed discovery + OPML bulk import
2233admin Jul 1, 2026
8eaa352
feat(mcp): expose sources/tasks/records as MCP tools
2233admin Jul 1, 2026
563f423
feat(channels): crawl4ai falls back to LLM extraction with no selectors
2233admin Jul 1, 2026
bacf535
fix: address gemini-code-assist review findings on PR #4
2233admin Jul 1, 2026
f6cdda3
fix: redact password fields in skill capture; crawl4ai error-taxonomy…
2233admin Jul 1, 2026
75474ee
fix: preserve retry taxonomy through ChannelFetchError; reconcile red…
2233admin Jul 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Force LF everywhere. Some frontend files in this repo's history carried
# mixed CRLF/LF line endings (pre-dating this file); an editor touching any
# line of such a file makes git's line-based diff show the whole file as
# changed, even when the real content delta is tiny (see
# frontend/src/components/ChannelConfigForm.tsx and
# frontend/src/pages/SourcesPage.tsx in refactor/thin-channel-thick-runner —
# `git diff -w` shows the real, small diff). This doesn't rewrite already-
# pushed history; it just stops new churn from the same cause.
* text=auto eol=lf
34 changes: 28 additions & 6 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,12 +483,20 @@ playwright install chromium

## 技能执行环路 e2e(live marker,Windows)

`tests/skills/test_execute_loop_live.py` 是唯一一个跑**真实本地 Chrome**的端到端测试:它通过
`tests/skills/test_execute_loop_live.py` **真实本地 Chromium 内核浏览器**的端到端测试:它通过
CDP 把整条 `perceive → act → extract → done` 环路、以及 headless 写前确认闸门,对着一个**本机静态
页面**真跑一遍(issue 07)。整个文件标了 `@pytest.mark.live`,所以默认的 `pytest -m "not live"`
(带 `--cov-fail-under=80`)**永远不需要浏览器**。浏览器是真的;只有便宜模型的**动作选择**被脚本
页面**真跑一遍(issue 07)。`tests/skills/test_cookie_persistence_live.py` 补一条:证 cookie/登录态
在两次独立 `connect_over_cdp`(attach → detach → 重新 attach,等价生产里两次任务复用同一 endpoint)
之间真的没丢——不是靠"CDP 挂接不落地新 context"这条架构推理去信,是真设 cookie 断开重连读回来验证过。

两个文件都标了 `@pytest.mark.live`,所以默认的 `pytest -m "not live"`(带 `--cov-fail-under=80`)
**永远不需要浏览器**。浏览器是真的;只有 `test_execute_loop_live.py` 里便宜模型的**动作选择**被脚本
固定(patch `backend.channels.skill_channel._build_model_call`),避免模型抖动让 live 测试变 flaky。

**Chrome 和 Edge 都真跑过**(2026-07-02):`connect_over_cdp` 走纯 CDP 协议,不认浏览器厂商——只要
给个 `--remote-debugging-port` endpoint 就行,不是 Chrome 专属。以下步骤两个浏览器通用,示例用 Chrome,
Edge 见下方"多浏览器"小节。

为新机器(win32)从零复现:

1. 安装 Playwright + 其 Chromium 驱动(每台机器一次性):
Expand All @@ -509,17 +517,31 @@ CDP 把整条 `perceive → act → extract → done` 环路、以及 headless
--no-first-run --no-default-browser-check
```

3. 指向它,并**只**跑这个 live 技能测试:
3. 指向它, live 技能测试(连带 cookie 持久化那条)

```powershell
$env:SKILL_LIVE_CDP_ENDPOINT = "http://127.0.0.1:9222"
uv run pytest -m live tests/skills/test_execute_loop_live.py
uv run pytest -m live tests/skills/test_execute_loop_live.py tests/skills/test_cookie_persistence_live.py
```

测试从 `SKILL_LIVE_CDP_ENDPOINT` 读 endpoint(回退到 `OPENCLI_CDP_ENDPOINT`)。**未设置时**它会
`pytest.skip(...)` 并给出可操作的提示,而不是 fail。被测页面由测试内嵌的 `ThreadingHTTPServer`
起在 `127.0.0.1:<随机端口>`,不依赖任何外部站点。

**多浏览器(Edge / 其他 Chromium 内核):**

```powershell
& "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" `
--remote-debugging-port=9223 --remote-debugging-address=127.0.0.1 `
--no-first-run --no-default-browser-check `
--user-data-dir="<某个持久目录>"
```

⚠️ **踩坑**:Edge 即使给了 `--remote-debugging-address=127.0.0.1`,实测仍只绑
`::1`(IPv6 loopback),`http://127.0.0.1:9223/json/version` 会超时。用
`http://localhost:9223`(Windows 上 `localhost` 优先解析到 `::1`)或直接
`http://[::1]:9223`。Chrome 没这毛病,`127.0.0.1` 直接通。

4. 默认套件**不含**它(CI / 本地日常都走这条,不需要 Chrome):

```powershell
Expand All @@ -529,7 +551,7 @@ CDP 把整条 `perceive → act → extract → done` 环路、以及 headless
确认它确实被默认排除:

```powershell
uv run pytest -m "not live" --collect-only -q | Select-String "test_execute_loop_live" # 应无匹配
uv run pytest -m "not live" --collect-only -q | Select-String "test_execute_loop_live|test_cookie_persistence_live" # 应无匹配
```

5. DB 说明:live 测试把一个**临时 SQLite**(默认内存库,`StaticPool` 单连接共享)绑进
Expand Down
4 changes: 4 additions & 0 deletions backend/api/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
agents,
browsers,
chat,
cookies,
dashboard,
nodes,
notifications,
providers,
records,
schedules,
skill_bridge,
skill_record,
skills,
sources,
system,
Expand All @@ -26,6 +28,7 @@
v1_router.include_router(agents.router)
v1_router.include_router(browsers.router)
v1_router.include_router(chat.router)
v1_router.include_router(cookies.router)
v1_router.include_router(nodes.router)
v1_router.include_router(providers.router)
v1_router.include_router(sources.router)
Expand All @@ -34,6 +37,7 @@
v1_router.include_router(schedules.router)
v1_router.include_router(skills.router)
v1_router.include_router(skill_bridge.router)
v1_router.include_router(skill_record.router)
v1_router.include_router(webhooks.router)
v1_router.include_router(notifications.router)
v1_router.include_router(workers.router)
Expand Down
116 changes: 84 additions & 32 deletions backend/api/v1/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from backend.schemas.schedule import CronScheduleUpdate
from backend.schemas.source import DataSourceUpdate
from backend.services import schedule_service, source_service, task_service
from backend.skills.toolcall import _is_xml_tool_model, _parse_tool_use, _safe_json

logger = logging.getLogger(__name__)

Expand All @@ -40,7 +41,9 @@
规则:
- 需要知道有哪些数据源时, 调 list_sources。
- 用户要启用/停用某个数据源时, 调 toggle_source。这是写操作, 系统不会立即执行, 会先让用户确认。
- 不要编造数据源 id; 先用 list_sources 拿到真实 id 再 toggle。
- 用户要配置 AI 处理(富化)阶段时(换模型 / 开关 AI), 先 list_providers 看现有提供商, 再 update_provider。
启用一个 provider = 采集成功后自动用它跑 AI 富化; 全部停用 = 不跑 AI。换模型改 default_model。
- 不要编造 id; 先用 list_* 拿到真实 id 再做写操作。
- 用中文简洁回答。"""


Expand Down Expand Up @@ -113,9 +116,33 @@
},
},
},
{
"type": "function",
"function": {
"name": "list_providers",
"description": "列出所有模型提供商 (返回 id / name / provider_type / default_model / base_url / enabled)。AI 富化阶段用哪个模型由 provider 决定。只读, 立即执行。",
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "update_provider",
"description": "配置 AI 处理阶段: 改某个模型提供商的默认模型, 或启用/停用它。启用一个 provider 后, 采集成功会自动用它跑 AI 富化; 全部停用则不跑 AI。写操作, 需用户确认。",
"parameters": {
"type": "object",
"properties": {
"provider_id": {"type": "string", "description": "模型提供商 id"},
"default_model": {"type": "string", "description": "默认模型名 (可选, 如 qwen3:4b)"},
"enabled": {"type": "boolean", "description": "启用/停用 (可选)"},
},
"required": ["provider_id"],
},
},
},
]

WRITE_TOOLS = {"toggle_source", "trigger_task", "update_schedule"}
WRITE_TOOLS = {"toggle_source", "trigger_task", "update_schedule", "update_provider"}


# ── request / response 模型 ─────────────────────────────────────────────────
Expand Down Expand Up @@ -195,6 +222,15 @@ async def _run_read_tool(db: AsyncSession, name: str, args: dict[str, Any]) -> A
{"id": t.id, "source_id": t.source_id, "status": t.status, "trigger_type": t.trigger_type}
for t in tasks
]
if name == "list_providers":
result = await db.execute(select(ModelProvider).order_by(ModelProvider.created_at.asc()))
return [
{
"id": p.id, "name": p.name, "provider_type": p.provider_type,
"default_model": p.default_model, "base_url": p.base_url, "enabled": p.enabled,
}
for p in result.scalars().all()
]
return {"error": f"unknown read tool: {name}"}


Expand Down Expand Up @@ -247,6 +283,29 @@ async def _build_proposal(db: AsyncSession, name: str, args: dict[str, Any]) ->
summary=f"修改调度「{schedule.name}」",
diff="; ".join(changes),
)
if name == "update_provider":
provider_id = args.get("provider_id", "")
provider = await db.get(ModelProvider, provider_id)
if not provider:
raise HTTPException(status_code=404, detail=f"模型提供商 {provider_id} 不存在")
out_args: dict[str, Any] = {"provider_id": provider_id}
changes: list[str] = []
if args.get("default_model") is not None:
new_model = str(args["default_model"])
out_args["default_model"] = new_model
changes.append(f"default_model {provider.default_model} → {new_model}")
if args.get("enabled") is not None:
out_args["enabled"] = bool(args["enabled"])
state = "启用" if out_args["enabled"] else "停用"
changes.append(f"{state} (enabled {provider.enabled} → {out_args['enabled']})")
if not changes:
raise HTTPException(status_code=400, detail="update_provider 未指定要改的字段 (default_model 或 enabled)")
return Proposal(
tool=name,
args=out_args,
summary=f"配置 AI 模型提供商「{provider.name}」",
diff="; ".join(changes),
)
raise HTTPException(status_code=400, detail=f"unknown write tool: {name}")


Expand Down Expand Up @@ -353,20 +412,31 @@ async def confirm(body: ConfirmRequest, db: AsyncSession = Depends(get_db)) -> A
logger.info("chat confirm | update_schedule %s %s", schedule.id, fields)
return ApiResponse.ok({"applied": True, "tool": proposal.tool, "summary": proposal.summary})

if proposal.tool == "update_provider":
provider = await db.get(ModelProvider, args.get("provider_id", ""))
if not provider:
raise HTTPException(status_code=404, detail="模型提供商不存在")
if "default_model" in args:
provider.default_model = str(args["default_model"])
if "enabled" in args:
provider.enabled = bool(args["enabled"])
await db.commit()
logger.info(
"chat confirm | update_provider %s %s",
provider.id, {k: args[k] for k in ("default_model", "enabled") if k in args},
)
return ApiResponse.ok({"applied": True, "tool": proposal.tool, "summary": proposal.summary})

raise HTTPException(status_code=400, detail=f"unknown proposal tool: {proposal.tool}")


# ── XML-style tool models (e.g. Qwable-v1: emits <tool_use> XML, not OpenAI tool_calls) ──
# Qwable-v1 (Qwen3.6-35B distill + Claude Fable-5 tool-use) emits custom
# <tool_use name="X" id="...">{json}</tool_use>
# XML in the message content instead of OpenAI `tool_calls`. We describe the
# tools in the system prompt as text and parse the XML ourselves.
XML_TOOL_MODELS = ("qwable",)

# matches both <tool_use name="X" .../> (self-closing) and <tool_use name="X" ...>{json}</tool_use>
_TOOL_USE_RE = re.compile(
r'<tool_use\s+name="([^"]+)"[^>]*?(?:/\s*>|>\s*(\{.*?\}|)\s*</tool_use>)', re.DOTALL
)
# XML tool-call parsing (XML_TOOL_MODELS / _TOOL_USE_RE / _is_xml_tool_model /
# _parse_tool_use / _safe_json) is shared with the skill execute core — the single
# source of truth lives in backend.skills.toolcall (imported above). Qwable-v1
# emits custom <tool_use name="X">{json}</tool_use> in the message content instead
# of OpenAI tool_calls; we describe the tools in the system prompt as text
# (XML_TOOL_TEXT) and parse the XML ourselves via the imported helpers.
_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)

XML_TOOL_TEXT = (
Expand All @@ -377,23 +447,13 @@ async def confirm(body: ConfirmRequest, db: AsyncSession = Depends(get_db)) -> A
"- toggle_source(source_id, enabled): 启用/停用数据源 (写)。\n"
"- trigger_task(source_id): 立即触发一次采集 (写)。\n"
"- update_schedule(schedule_id, cron_expression?, enabled?): 改调度 cron 或启停 (写)。\n"
"- list_providers(): 列出模型提供商 (id/name/default_model/enabled)。\n"
"- update_provider(provider_id, default_model?, enabled?): 配置 AI 富化阶段的模型提供商, 改模型或启停 (写)。\n"
'需要调用工具时, 严格输出 XML: <tool_use name="工具名" id="toolu_1">{json 参数}</tool_use>\n'
"先用 list_* 拿到真实 id 再做写操作。不要用 markdown 代码块。"
)


def _is_xml_tool_model(model: str) -> bool:
m = model.lower()
return any(k in m for k in XML_TOOL_MODELS)


def _parse_tool_use(content: str) -> list[tuple[str, dict[str, Any]]]:
calls: list[tuple[str, dict[str, Any]]] = []
for match in _TOOL_USE_RE.finditer(content or ""):
calls.append((match.group(1), _safe_json(match.group(2) or "{}")))
return calls


async def _chat_xml(client: Any, model: str, system: str, body: ChatRequest, db: AsyncSession) -> ApiResponse:
"""Tool loop for XML-style models (parse <tool_use> from content, feed results back as text)."""
messages: list[dict[str, Any]] = [{"role": "system", "content": system + XML_TOOL_TEXT}]
Expand Down Expand Up @@ -428,11 +488,3 @@ async def _chat_xml(client: Any, model: str, system: str, body: ChatRequest, db:
)

return ApiResponse.ok(ChatReply(type="message", content="(达到工具调用步数上限, 请换个说法再试)"))


def _safe_json(raw: str) -> dict[str, Any]:
try:
value = json.loads(raw or "{}")
return value if isinstance(value, dict) else {}
except json.JSONDecodeError:
return {}
28 changes: 28 additions & 0 deletions backend/api/v1/cookies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Admin endpoint for CookieCloud sync — manual trigger only (v1; no scheduled
sync, see backend/auth/cookiecloud_sync.py docstring). Credentials are passed
per call, not persisted, matching the "no new settings storage" tight scope."""

from __future__ import annotations

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel

from backend.auth.cookiecloud_sync import CookieCloudSyncError, sync_from_cookiecloud
from backend.schemas.common import ApiResponse

router = APIRouter(prefix="/cookies", tags=["cookies"])


class CookieCloudSyncRequest(BaseModel):
url: str
uuid: str
password: str


@router.post("/sync", response_model=ApiResponse[dict])
async def sync_cookies(body: CookieCloudSyncRequest) -> ApiResponse:
try:
synced = await sync_from_cookiecloud(body.url, body.uuid, body.password)
except CookieCloudSyncError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return ApiResponse.ok({"synced": synced})
Loading
Loading