Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-nj
This commit is contained in:
commit
5a4881053d
32 files changed
+787
-101
No files matched your search
@@ -3,6 +3,7 @@
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
frontend-web/.vscode/
|
||||
node_modules/
|
||||
# Claude Code local-only settings (per-user permissions, not shared)
|
||||
|
||||
Vendored
+22
-22
@@ -2,7 +2,7 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "DeerFlow Backend FastAPI (8001)",
|
||||
"name": "DeerFlow: Backend FastAPI (8001)",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "uvicorn",
|
||||
@@ -11,54 +11,54 @@
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"8001",
|
||||
"--reload"
|
||||
"8001"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/offline-backend-20260512/backend",
|
||||
"python": "${workspaceFolder}/offline-backend-20260512/backend/.venv/Scripts/python.exe",
|
||||
"envFile": "${workspaceFolder}/offline-backend-20260512/.env",
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/offline-backend-20260512/backend;${workspaceFolder}/offline-backend-20260512/backend/.deer-flow/python-vendor",
|
||||
"GATEWAY_CORS_ORIGINS": "http://localhost:5174,http://127.0.0.1:5174,http://localhost:8080,http://127.0.0.1:8080"
|
||||
"GATEWAY_CORS_ORIGINS": "http://localhost:5174,http://127.0.0.1:5174,http://localhost:8080,http://127.0.0.1:8080",
|
||||
"UV_LINK_MODE": "copy",
|
||||
"WEKNORA_ADMIN_PASSWORD": "1qaz@WSX"
|
||||
},
|
||||
"console": "integratedTerminal",
|
||||
"redirectOutput": true,
|
||||
"justMyCode": false,
|
||||
"subProcess": true
|
||||
},
|
||||
{
|
||||
"name": "DeerFlow Frontend Vite (5174)",
|
||||
"type": "node",
|
||||
"name": "DeerFlow: Frontend Vite (5174)",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": [
|
||||
"exec",
|
||||
"vite",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"5174"
|
||||
],
|
||||
"command": "npm.cmd run dev -- --host 0.0.0.0 --port 5174",
|
||||
"cwd": "${workspaceFolder}/frontend-web",
|
||||
"env": {
|
||||
"VITE_BACKEND_BASE_URL": "http://127.0.0.1:8001",
|
||||
"VITE_LANGGRAPH_BASE_URL": "http://127.0.0.1:8001/api"
|
||||
},
|
||||
"console": "integratedTerminal",
|
||||
"outputCapture": "std",
|
||||
"serverReadyAction": {
|
||||
"pattern": "Local:\\s+(https?://[^\\s]+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "debugWithChrome",
|
||||
"webRoot": "${workspaceFolder}/frontend-web"
|
||||
"action": "openExternally"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "DeerFlow: Open Frontend in Chrome",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "DeerFlow: Start Full Stack and Wait",
|
||||
"command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"Start-Process 'http://127.0.0.1:5174/'\"",
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "DeerFlow Full Stack (Backend + Frontend)",
|
||||
"name": "DeerFlow: Full Stack (Backend + Frontend)",
|
||||
"preLaunchTask": "DeerFlow: Stop Full Stack",
|
||||
"configurations": [
|
||||
"DeerFlow Backend FastAPI (8001)",
|
||||
"DeerFlow Frontend Vite (5174)"
|
||||
"DeerFlow: Backend FastAPI (8001)",
|
||||
"DeerFlow: Frontend Vite (5174)"
|
||||
],
|
||||
"stopAll": true
|
||||
}
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}/offline-backend-20260512/backend/.venv/Scripts/python.exe",
|
||||
"python.terminal.activateEnvironment": true,
|
||||
"typescript.tsdk": "frontend-web/node_modules/typescript/lib",
|
||||
"npm.packageManager": "npm"
|
||||
}
|
||||
Vendored
+106
-6
@@ -2,9 +2,61 @@
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "DeerFlow:检查本地开发环境",
|
||||
"label": "DeerFlow: Start Full Stack and Wait",
|
||||
"type": "process",
|
||||
"command": "powershell.exe",
|
||||
"args": [
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
"${workspaceFolder}/scripts/start-all-and-wait-frontend.ps1"
|
||||
],
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow: Start Full Stack",
|
||||
"type": "process",
|
||||
"command": "powershell.exe",
|
||||
"args": [
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
"${workspaceFolder}/scripts/start-all.ps1"
|
||||
],
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow: Stop Full Stack",
|
||||
"type": "process",
|
||||
"command": "powershell.exe",
|
||||
"args": [
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
"${workspaceFolder}/scripts/stop-all.ps1"
|
||||
],
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow: Status",
|
||||
"type": "process",
|
||||
"command": "powershell.exe",
|
||||
"args": [
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
"${workspaceFolder}/scripts/status.ps1"
|
||||
],
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow: Check Local Dev Environment",
|
||||
"type": "shell",
|
||||
"command": "$backendPython = '${workspaceFolder}\\offline-backend-20260512\\backend\\.venv\\Scripts\\python.exe'; if (-not (Test-Path -LiteralPath $backendPython)) { throw '后端虚拟环境不存在,请先在 backend 目录执行 uv sync' }; & $backendPython -c \"import uvicorn; print('Backend Python OK')\"; pnpm --dir '${workspaceFolder}\\frontend-web' exec vite --version",
|
||||
"command": "$backendPython = '${workspaceFolder}\\offline-backend-20260512\\backend\\.venv\\Scripts\\python.exe'; if (-not (Test-Path -LiteralPath $backendPython)) { throw 'Backend virtual environment is missing. Run uv sync in offline-backend-20260512/backend first.' }; & $backendPython -c \"import uvicorn; print('Backend Python OK')\"; npm --prefix '${workspaceFolder}\\frontend-web' exec vite -- --version",
|
||||
"options": {
|
||||
"shell": {
|
||||
"executable": "powershell.exe",
|
||||
@@ -19,10 +71,38 @@
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow:前端生产构建",
|
||||
"label": "DeerFlow: Install Frontend Dependencies",
|
||||
"type": "process",
|
||||
"command": "pnpm",
|
||||
"command": "npm",
|
||||
"args": [
|
||||
"ci",
|
||||
"--no-audit",
|
||||
"--no-fund"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend-web"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow: Frontend Typecheck",
|
||||
"type": "process",
|
||||
"command": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"typecheck"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend-web"
|
||||
},
|
||||
"problemMatcher": "$tsc"
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow: Frontend Build",
|
||||
"type": "process",
|
||||
"command": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
@@ -31,7 +111,26 @@
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow:WeKnora 专项测试",
|
||||
"label": "DeerFlow: Backend Tests",
|
||||
"type": "process",
|
||||
"command": "${workspaceFolder}/offline-backend-20260512/backend/.venv/Scripts/python.exe",
|
||||
"args": [
|
||||
"-m",
|
||||
"pytest",
|
||||
"tests/",
|
||||
"-v"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/offline-backend-20260512/backend",
|
||||
"env": {
|
||||
"PYTHONPATH": ".",
|
||||
"WEKNORA_ADMIN_PASSWORD": "1qaz@WSX"
|
||||
}
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow: WeKnora Backend Tests",
|
||||
"type": "process",
|
||||
"command": "${workspaceFolder}/offline-backend-20260512/backend/.venv/Scripts/python.exe",
|
||||
"args": [
|
||||
@@ -43,7 +142,8 @@
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/offline-backend-20260512/backend",
|
||||
"env": {
|
||||
"PYTHONPATH": "."
|
||||
"PYTHONPATH": ".",
|
||||
"WEKNORA_ADMIN_PASSWORD": "1qaz@WSX"
|
||||
}
|
||||
},
|
||||
"problemMatcher": []
|
||||
|
||||
@@ -295,17 +295,19 @@ function ToolCall({
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
if (name === "web_search" || searchItems.length > 0) {
|
||||
const query = resolveSearchQuery(args, result);
|
||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||
if (query) {
|
||||
label =
|
||||
name === "web_search"
|
||||
? t.toolCalls.searchOnWebFor(query)
|
||||
: t.toolCalls.searchFor(query);
|
||||
}
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
|
||||
if (name === "web_search" || searchItems.length > 0) {
|
||||
const query = resolveSearchQuery(args, result);
|
||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||
if (query) {
|
||||
label =
|
||||
name === "web_search"
|
||||
? `知识库检索 "${query}"`
|
||||
: t.toolCalls.searchFor(query);
|
||||
} else if (name === "web_search") {
|
||||
label = "知识库检索";
|
||||
}
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
|
||||
{searchItems.length > 0 && (
|
||||
<WebSearchResultsList results={searchItems} />
|
||||
)}
|
||||
@@ -520,12 +522,12 @@ function ToolCall({
|
||||
icon={WrenchIcon}
|
||||
></ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface GenericCoTStep<T extends string = string> {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
}
|
||||
}
|
||||
|
||||
interface GenericCoTStep<T extends string = string> {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
type: T;
|
||||
}
|
||||
|
||||
|
||||
+5
-3
@@ -28,7 +28,7 @@ function cssVar(name: string, fallback = ""): string {
|
||||
function collectThemePayload(): DeerFlowThemePayload {
|
||||
const isDark =
|
||||
document.documentElement.classList.contains("dark") ||
|
||||
document.documentElement.dataset.theme === "dark";
|
||||
document.documentElement.getAttribute("theme-mode") === "dark";
|
||||
return {
|
||||
type: "deerflow-weknora-theme",
|
||||
theme: isDark ? "dark" : "light",
|
||||
@@ -91,7 +91,9 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
const path = session.frame_path.startsWith("/")
|
||||
? session.frame_path
|
||||
: `/${session.frame_path}`;
|
||||
setFrameSrc(`${base}${path}`);
|
||||
const nextUrl = new URL(`${base}${path}`);
|
||||
nextUrl.searchParams.set("theme", collectThemePayload().theme);
|
||||
setFrameSrc(nextUrl.toString());
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
@@ -112,7 +114,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
const observer = new MutationObserver(send);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style", "data-theme"],
|
||||
attributeFilter: ["class", "style", "data-theme", "theme-mode"],
|
||||
});
|
||||
window.addEventListener("storage", send);
|
||||
return () => {
|
||||
|
||||
@@ -56,7 +56,7 @@ ELEMENT_STATE_FILE=/app/backend/.deer-flow/element-bot-token
|
||||
# PYTHON_PACKAGE_INDEX_URL=http://your-python-mirror/simple
|
||||
# PYTHON_EXTRA_INDEX_URL=
|
||||
# PYTHON_TRUSTED_HOST=your-python-mirror
|
||||
MYSQL_DATABASE_URL=mysql+asyncmy://deerflow:deerflow123@192.168.0.123:3306/deerflow?charset=utf8mb4
|
||||
MYSQL_DATABASE_URL=mysql+aiomysql://deerflow:deerflow123@192.168.0.123:3306/deerflow?charset=utf8mb4
|
||||
DEER_FLOW_NOTEBOOK_API_BASE=http://127.0.0.1:8003
|
||||
# 圆桌后台作业网关:缺省即 inprocess(进程内驱动真模型,后台挂起开箱即用);
|
||||
# 仅前端联调时显式设 simulated(真实进度但不调真模型)。本行可删,留作文档。
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
- `offline-runtime-requirements.txt`:固定版本的完整依赖闭包。
|
||||
- `prepare_wheelhouse.sh`:联网机器下载 Linux/Python 3.12 wheel,并验证传递依赖完整性。
|
||||
- `start_backend.sh`:Docker 启动脚本,包含 `croniter`、`hindsight-client`、`aiomysql` 和 Redis 客户端的缺包补装逻辑。
|
||||
- `wheelhouse/`:24 个 wheel 和 `SHA256SUMS`,需要随发布目录一起复制。
|
||||
- `start_backend.sh`:Docker 启动脚本,包含 `croniter`、`hindsight-client`、aiomysql 兼容组和 Redis 客户端的缺包补装逻辑。
|
||||
- `wheelhouse/`:26 个 wheel 和 `SHA256SUMS`,需要随发布目录一起复制。
|
||||
|
||||
## 联网机器准备
|
||||
|
||||
@@ -24,8 +24,9 @@ bash prepare_wheelhouse.sh
|
||||
- `hindsight-client==0.6.2`
|
||||
- `redis==5.2.1`
|
||||
- `aiomysql==0.3.2`
|
||||
- `SQLAlchemy==2.0.51`
|
||||
|
||||
上述依赖在 Python 3.12/Linux x86_64 下的 20 个传递依赖(包括 `PyMySQL==1.2.0`)也已锁定在 `offline-runtime-requirements.txt`。
|
||||
上述依赖在 Python 3.12/Linux x86_64 下的 21 个传递依赖(包括 `PyMySQL==1.2.0` 和 `greenlet==3.2.5`)也已锁定在 `offline-runtime-requirements.txt`。
|
||||
|
||||
国内镜像示例:
|
||||
|
||||
@@ -58,7 +59,7 @@ bash start_backend.sh
|
||||
docker logs -f deerflow-backend
|
||||
```
|
||||
|
||||
脚本会先尝试导入依赖,仅在缺少模块时从 `/app/wheelhouse` 安装。`aiomysql` 补装默认开启,可通过 `AIOMYSQL_INSTALL_DEPS=0` 禁用,或通过 `AIOMYSQL_PIP_PACKAGES` 覆盖默认的 `aiomysql==0.3.2`。
|
||||
脚本会先尝试导入依赖,仅在缺少模块或 SQLAlchemy 的 aiomysql `ping(reconnect)` 适配接口不兼容时从 `/app/wheelhouse` 安装。aiomysql 补装默认开启,并在一次命令中安装兼容组 `aiomysql==0.3.2 PyMySQL==1.2.0 SQLAlchemy==2.0.51`,避免旧版 SQLAlchemy 启动时出现 `ping() missing 1 required positional argument: reconnect`。可通过 `AIOMYSQL_INSTALL_DEPS=0` 禁用,或通过 `AIOMYSQL_PIP_PACKAGES` 覆盖整个兼容组。
|
||||
|
||||
MySQL 连接 URL 使用 aiomysql 驱动时应写为 `mysql+aiomysql://user:password@host:3306/database?charset=utf8mb4`。
|
||||
|
||||
|
||||
@@ -956,6 +956,8 @@ Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` me
|
||||
|
||||
`llmwiki.weknora.api_base_url` is the provider switch: non-empty enables WeKnora, empty preserves the legacy LLMWiki UI. WeKnora is independently deployed and owns PostgreSQL/Redis/object storage/vector storage/parsers/models; DeerFlow config contains only `api_base_url` and optional `web_base_url`. DeerFlow deliberately sends no WeKnora credential, so the WeKnora endpoint must be restricted to a trusted network and allow DeerFlow traffic itself or through its reverse proxy. There is intentionally no `default_embedding_model_id`: `WeKnoraClient` queries `/api/v1/models` and uses WeKnora's active Embedding model when creating a KB; Wiki creation additionally resolves an active KnowledgeQA model inside WeKnora for synthesis. The native BFF is `app/gateway/routers/llmwiki.py`; ownership/publication mappings live in `deerflow.persistence.llmwiki`; the browser only sees DeerFlow mapping IDs. Knowledge-base owners publish immediately (`private` -> `published`) without a review queue, and can unpublish ordinary bases. The conversation-deposit KB is system-owned, always published, shared by all DeerFlow users, excluded from My Knowledge, and immutable through user/admin detail operations; recognition requires both the reserved name and `system` owner. Existing same-name remote mappings are adopted in place as the system deposit, preserving mapping ids and document links. Its user-facing description, generated Markdown source and tags use `cmzs` branding; list/detail reads synchronize stale remote descriptions, and search results, source previews and embedded-detail JSON recursively replace legacy brand text in historical conversation-deposit payloads. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base list/search views are scoped to the current DeerFlow account even for admins; frontend LLMWiki caches include the live account id, and admin write power does not make other users' private KBs appear as owned. Only `system_role === "admin"` sees the WeKnora connection-config tab. Knowledge cards navigate to a dedicated WeKnora-style detail route. Wiki-enabled bases render and default to `Wiki -> documents -> graph`; standard bases omit Wiki and render `documents -> graph`. The detail page supports sequential multi-file upload with an aggregate result for writable ordinary bases, hides it for public read-only/system-deposit bases, and uses a generic loading label while the embed session starts. It also supports URL and published manual-Markdown imports, browses real Wiki pages/content, proxies authenticated original-file previews, pages through `/chunks/{knowledge_id}`, and renders the Wiki page graph from `/knowledgebase/{kb_id}/wiki/graph`; document details use a right sheet rather than the removed KB modal. The credential-free proxy uses one WeKnora service identity, so per-user separation is enforced by DeerFlow mappings while all remote KBs physically share that identity's WeKnora workspace. WeKnora iframe detail pages first call `/api/llmwiki/knowledge-bases/{id}/weknora/session` through the normal DeerFlow API auth path, then load `/platform/knowledge-bases/{weknora_id}` with a signed `deerflow_weknora_embed` cookie for `/platform`, `/assets`, `/locales`, root assets, and API calls. Injected fetch/XHR rewriting sends all iframe `/api/v1/*`, including colliding `/api/v1/auth/*`, through `/api/llmwiki/weknora-embed/api/v1/*`; Gateway auth/CSRF bypass only applies to those signed iframe proxy requests, the proxy still revalidates the mapped KB, and cookie security honors `X-Forwarded-Proto` for HTTPS reverse proxies. `llmwiki_search` is conditionally registered in `tools.py`, is read-only, and rechecks the current DeerFlow user, agent `llmwiki_knowledge_base_ids`, and thread-context selection on every call. Source previews prefer the cleaned Wiki/LLMWiki page text when a retrieved chunk maps to a Wiki page, with raw split chunks as the fallback. Agent bindings use an inline WeKnora-style searchable/grouped multi-select in the standard agent create/detail UI; new chats use the same interaction pattern as a compact composer dropdown, and selections are written to thread metadata/context and restored on reopen. The DeerFlow frontend is native React and the WeKnora frontend is not modified or embedded. Full deployment and behavior notes: `docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md`. Tests: `tests/test_llmwiki_weknora.py`.
|
||||
|
||||
The embedded detail proxy hides WeKnora's outer `.main > .aside_box` application sidebar and expands the route outlet, but deliberately keeps the Wiki page's internal directory/index sidebar.
|
||||
|
||||
### Embedded Client (`packages/harness/deerflow/client.py`)
|
||||
|
||||
`DeerFlowClient` provides direct in-process access to all DeerFlow capabilities without HTTP services. All return types align with the Gateway API response schemas, so consumer code works identically in HTTP and embedded modes.
|
||||
@@ -1184,7 +1186,7 @@ and timestamps; do not persist Markdown content or call a formal delivery
|
||||
interface. Previews continue to read the DeerFlow artifact by `threadId + path`.
|
||||
|
||||
See `docs/` directory for detailed documentation:
|
||||
- [../README.md](../README.md) - Offline Docker wheelhouse preparation and startup workflow. The pinned versions in `../offline-runtime-requirements.txt` and `../start_backend.sh` must stay synchronized. Startup checks `croniter`, `hindsight-client`, `aiomysql`, and Redis, installing missing packages from wheelhouse only; `aiomysql==0.3.2` includes the pinned `PyMySQL==1.2.0` transitive dependency. The download target defaults to CPython 3.12 on Linux x86_64 and verifies dependency closure using wheelhouse only.
|
||||
- [../README.md](../README.md) - Offline Docker wheelhouse preparation and startup workflow. The pinned versions in `../offline-runtime-requirements.txt` and `../start_backend.sh` must stay synchronized. Startup checks `croniter`, `hindsight-client`, the aiomysql compatibility bundle, and Redis, installing missing or incompatible packages from wheelhouse only. The bundle installs `aiomysql==0.3.2`, `PyMySQL==1.2.0`, and `SQLAlchemy==2.0.51` together; the startup probe also verifies that SQLAlchemy's adapted `ping(reconnect=False)` signature is present, preventing the old-adapter missing-`reconnect` failure. The download target defaults to CPython 3.12 on Linux x86_64 and verifies dependency closure using wheelhouse only.
|
||||
- [CONFIGURATION.md](docs/CONFIGURATION.md) - Configuration options
|
||||
- [ARCHITECTURE.md](docs/ARCHITECTURE.md) - Architecture details
|
||||
- [API.md](docs/API.md) - API reference
|
||||
|
||||
@@ -81,6 +81,7 @@ Per-thread isolated execution with virtual path translation:
|
||||
- **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory
|
||||
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
|
||||
- **Conditional WeKnora LLMWiki**: a non-empty `llmwiki.weknora.api_base_url` enables the DeerFlow-native WeKnora BFF and UI; an empty address preserves the existing LLMWiki page. WeKnora remains independently deployed and owns its database, Redis, storage, parsers and models. DeerFlow keeps only the API/admin addresses, user/publication mappings, agent knowledge-base bindings and per-conversation selections; it stores and sends no WeKnora credential, so the endpoint must be restricted to a trusted network and allow DeerFlow traffic. Owners publish directly to the public LLMWiki without an approval queue for ordinary bases; the system-owned conversation-deposit base is always published, shared by all users, excluded from My Knowledge, immutable through user/admin detail operations, and recognized by both its reserved name and `system` owner. An existing same-name remote mapping is adopted in place as the system deposit so its mapping id and document links stay valid. Its user-facing knowledge-base description and generated document metadata use the `cmzs` brand; list/detail reads synchronize stale remote descriptions, while conversation-deposit search results, source previews and embedded-detail JSON rewrite legacy brand text to `cmzs`. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base lists are scoped to the current DeerFlow account even for admins, while frontend LLMWiki query caches also include the active account id so switching accounts cannot reuse another account's list/detail data. Admins remain the only role that sees the WeKnora connection-config tab. Knowledge cards open a dedicated WeKnora-style detail route: Wiki-enabled bases default to Wiki, then documents and graph; standard bases hide Wiki and default to documents. Writable ordinary knowledge-base details expose a native multi-file upload action that submits files sequentially and reports aggregate success/failure; read-only public bases and the system deposit base do not. It supports file/URL/manual-Markdown imports, Wiki page browsing, document preview, real parsed chunks and Wiki graph nodes/edges. New chats use a compact WeKnora-style searchable/grouped multi-select inside the composer, while agent create/edit pages bind spaces through an inline panel. A single proxy service identity means DeerFlow users are isolated logically while their WeKnora entities share that identity's workspace. The read-only `llmwiki_search` tool reauthorizes the current DeerFlow user on every call and renders credential-free source previews; source previews prefer cleaned Wiki/LLMWiki text when a retrieved chunk maps to a Wiki page, with raw chunks as the fallback. The detail page first creates a signed iframe session through the normal DeerFlow API auth path, then points the iframe directly at the proxied WeKnora `/platform/...` route using the signed `deerflow_weknora_embed` cookie; global auth/CSRF bypass is limited to those signed iframe proxy requests, and HTTPS cookie attributes honor `X-Forwarded-Proto` behind reverse proxies. The iframe proxy keeps WeKnora's root-relative `/platform`, `/assets`, `/locales`, and `/api/v1/*` paths, but is registered last through `llmwiki.proxy_router` so it cannot intercept first-party Gateway APIs such as `POST /api/v1/auth/login/username`. See [docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md](docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md).
|
||||
- **Embedded WeKnora detail chrome**: the proxy hides WeKnora's application sidebar (`.main > .aside_box`) and expands the detail outlet to the full iframe width, while preserving the Wiki page's own index/navigation panel.
|
||||
- **File-write safety**: `str_replace` serializes read-modify-write per `(sandbox.id, path)` so isolated sandboxes keep concurrency even when virtual paths match
|
||||
- **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access)
|
||||
|
||||
|
||||
@@ -101,9 +101,9 @@ def _agent_bound_ids(body: Any) -> list[str]:
|
||||
|
||||
|
||||
def resolve_requested_llmwiki_knowledge_base_ids(body: Any) -> list[str]:
|
||||
"""Return selected ids plus custom-agent defaults, de-duplicated in order."""
|
||||
"""Return knowledge bases explicitly selected for the current conversation."""
|
||||
|
||||
return list(dict.fromkeys([*_agent_bound_ids(body), *_body_selected_ids(body)]))
|
||||
return _body_selected_ids(body)
|
||||
|
||||
|
||||
def llmwiki_selection_requested(body: Any) -> bool:
|
||||
|
||||
@@ -548,6 +548,42 @@ html.deerflow-weknora-embed .knowledge-layout {{
|
||||
min-width: 0 !important;
|
||||
background: var(--td-bg-color-page) !important;
|
||||
}}
|
||||
html.deerflow-weknora-embed [data-deerflow-weknora-chrome="hidden"],
|
||||
html.deerflow-weknora-embed #app > .main > .aside_box,
|
||||
html.deerflow-weknora-embed #app > aside,
|
||||
html.deerflow-weknora-embed #app > .sidebar,
|
||||
html.deerflow-weknora-embed #app > .side-bar,
|
||||
html.deerflow-weknora-embed #app > .side-menu,
|
||||
html.deerflow-weknora-embed #app > .t-layout__sider,
|
||||
html.deerflow-weknora-embed #app > .t-layout-sider,
|
||||
html.deerflow-weknora-embed #app > .layout-sider,
|
||||
html.deerflow-weknora-embed #app > .layout-sidebar,
|
||||
html.deerflow-weknora-embed #app > .platform-sidebar,
|
||||
html.deerflow-weknora-embed #app > .platform-sider,
|
||||
html.deerflow-weknora-embed #app > .app-sidebar,
|
||||
html.deerflow-weknora-embed #app > .app-sider,
|
||||
html.deerflow-weknora-embed #app > .t-layout > aside,
|
||||
html.deerflow-weknora-embed #app > .t-layout > .t-layout__sider,
|
||||
html.deerflow-weknora-embed #app > .t-layout > .t-layout-sider,
|
||||
html.deerflow-weknora-embed #app > .layout > aside,
|
||||
html.deerflow-weknora-embed #app > .layout > .layout-sider,
|
||||
html.deerflow-weknora-embed #app > .layout > .layout-sidebar {{
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}}
|
||||
html.deerflow-weknora-embed [data-deerflow-weknora-content="true"],
|
||||
html.deerflow-weknora-embed #app,
|
||||
html.deerflow-weknora-embed .t-layout,
|
||||
html.deerflow-weknora-embed .t-layout__content,
|
||||
html.deerflow-weknora-embed .layout-content,
|
||||
html.deerflow-weknora-embed .platform-content {{
|
||||
margin-left: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
}}
|
||||
html.deerflow-weknora-embed .document-header {{
|
||||
padding-left: 32px !important;
|
||||
padding-right: 32px !important;
|
||||
@@ -644,10 +680,17 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
|
||||
function tone(color, alpha) {{
|
||||
return `color-mix(in srgb, ${{color}} ${{Math.round(alpha * 100)}}%, transparent)`;
|
||||
}}
|
||||
let applyingTheme = false;
|
||||
function applyTheme(message) {{
|
||||
if (!message || message.type !== "deerflow-weknora-theme") return;
|
||||
applyingTheme = true;
|
||||
window.__deerflowWeKnoraTheme = message;
|
||||
const vars = message.vars || {{}};
|
||||
const dark = message.theme === "dark";
|
||||
root.classList.toggle("dark", dark);
|
||||
root.setAttribute("theme-mode", dark ? "dark" : "light");
|
||||
root.setAttribute("data-theme-mode", dark ? "dark" : "light");
|
||||
root.setAttribute("data-theme", dark ? "dark" : "light");
|
||||
const pairs = {{
|
||||
"--td-brand-color": vars.primary,
|
||||
"--td-brand-color-hover": vars.primary,
|
||||
@@ -675,8 +718,31 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
|
||||
if (value) root.style.setProperty(key, value);
|
||||
}}
|
||||
root.style.colorScheme = dark ? "dark" : "light";
|
||||
try {{
|
||||
localStorage.setItem("theme-mode", dark ? "dark" : "light");
|
||||
localStorage.setItem("tdesign-starter-theme-mode", dark ? "dark" : "light");
|
||||
localStorage.setItem("weknora-theme-mode", dark ? "dark" : "light");
|
||||
}} catch (_) {{}}
|
||||
window.setTimeout(() => {{ applyingTheme = false; }}, 0);
|
||||
}}
|
||||
window.addEventListener("message", (event) => applyTheme(event.data));
|
||||
const urlTheme = new URLSearchParams(location.search).get("theme");
|
||||
if (urlTheme === "dark" || urlTheme === "light") {{
|
||||
applyTheme({{
|
||||
type: "deerflow-weknora-theme",
|
||||
theme: urlTheme,
|
||||
vars: {{
|
||||
primary: urlTheme === "dark" ? "#2b81ff" : "#0062d9",
|
||||
background: urlTheme === "dark" ? "#0e172b" : "#ffffff",
|
||||
foreground: urlTheme === "dark" ? "#eff2f5" : "#202a3a",
|
||||
card: urlTheme === "dark" ? "#1a2539" : "#ffffff",
|
||||
muted: urlTheme === "dark" ? "#232e40" : "#f3f5f8",
|
||||
mutedForeground: urlTheme === "dark" ? "#8e9aa4" : "#7f7f7f",
|
||||
border: urlTheme === "dark" ? "rgba(255,255,255,0.12)" : "#dbdbdb",
|
||||
destructive: "#d54941"
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
|
||||
const allowedPath = cfg.allowedPath;
|
||||
function allowedUrl(url) {{
|
||||
@@ -705,6 +771,94 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
|
||||
try {{ node.remove(); }} catch (_) {{}}
|
||||
}});
|
||||
}}
|
||||
let themeRefreshTimer = 0;
|
||||
function refreshThemeSoon() {{
|
||||
if (applyingTheme || !window.__deerflowWeKnoraTheme || themeRefreshTimer) return;
|
||||
themeRefreshTimer = window.setTimeout(() => {{
|
||||
themeRefreshTimer = 0;
|
||||
applyTheme(window.__deerflowWeKnoraTheme);
|
||||
}}, 80);
|
||||
}}
|
||||
const shellSidebarSelector = [
|
||||
".main > .aside_box",
|
||||
"aside",
|
||||
".sidebar",
|
||||
".side-bar",
|
||||
".side-menu",
|
||||
".nav-sidebar",
|
||||
".navigation-sidebar",
|
||||
".menu-sidebar",
|
||||
".t-layout__sider",
|
||||
".t-layout-sider",
|
||||
".layout-sider",
|
||||
".layout-sidebar",
|
||||
".platform-sidebar",
|
||||
".platform-sider",
|
||||
".app-sidebar",
|
||||
".app-sider",
|
||||
"[class*='Sidebar']",
|
||||
"[class*='sidebar']",
|
||||
"[class*='SideBar']",
|
||||
"[class*='sideBar']",
|
||||
"[class*='Sider']",
|
||||
"[class*='sider']"
|
||||
].join(",");
|
||||
function isInsideKnowledgeDetail(node) {{
|
||||
return Boolean(
|
||||
node.closest(
|
||||
".knowledge-layout,.document-layout,.document-detail,.wiki-layout,.wiki-page,.graph-layout,.graph-page,[class*='knowledge-detail'],[class*='wiki-detail']"
|
||||
)
|
||||
);
|
||||
}}
|
||||
function isLikelyOuterSidebar(node) {{
|
||||
if (!node || node === document.body || node === document.documentElement) return false;
|
||||
if (isInsideKnowledgeDetail(node)) return false;
|
||||
const rect = node.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return false;
|
||||
const style = getComputedStyle(node);
|
||||
const className = String(node.className || "").toLowerCase();
|
||||
const namedLikeShell =
|
||||
/sidebar|side-bar|side_menu|side-menu|sider|layout-sider|platform-sider|platform-sidebar|app-sider|app-sidebar|nav-sidebar|menu-sidebar/.test(
|
||||
className
|
||||
);
|
||||
const nearLeft = rect.left <= 4;
|
||||
const sidebarWidth = rect.width >= 40 && rect.width <= Math.min(360, Math.max(120, window.innerWidth * 0.42));
|
||||
const tallEnough = rect.height >= Math.max(240, window.innerHeight * 0.45);
|
||||
const topShellChild =
|
||||
node.parentElement === document.body ||
|
||||
node.parentElement === document.getElementById("app") ||
|
||||
node.parentElement?.parentElement === document.getElementById("app") ||
|
||||
node.closest("#app") === node.parentElement?.closest("#app");
|
||||
const positionedShell = style.position === "fixed" || style.position === "sticky";
|
||||
const text = (node.textContent || "").slice(0, 400);
|
||||
const containsAppNav = /知识库|工作台|空间|租户|模型|智能体|设置|Knowledge|Workspace|Agent|Model|Tenant/.test(text);
|
||||
return namedLikeShell && nearLeft && sidebarWidth && tallEnough && (topShellChild || positionedShell || containsAppNav);
|
||||
}}
|
||||
function hideWeKnoraShell() {{
|
||||
try {{
|
||||
document.querySelectorAll(shellSidebarSelector).forEach((node) => {{
|
||||
if (isLikelyOuterSidebar(node)) {{
|
||||
node.dataset.deerflowWeknoraChrome = "hidden";
|
||||
node.setAttribute("aria-hidden", "true");
|
||||
}}
|
||||
}});
|
||||
document
|
||||
.querySelectorAll(
|
||||
"#app,.t-layout,.t-layout__content,.layout-content,.platform-content,.platform-route-outlet,.main"
|
||||
)
|
||||
.forEach((node) => {{
|
||||
if (!isInsideKnowledgeDetail(node)) {{
|
||||
node.dataset.deerflowWeknoraContent = "true";
|
||||
const element = node;
|
||||
element.style.marginLeft = "0";
|
||||
element.style.paddingLeft = "0";
|
||||
element.style.left = "0";
|
||||
element.style.width = "100%";
|
||||
element.style.maxWidth = "none";
|
||||
}}
|
||||
}});
|
||||
}} catch (_) {{}}
|
||||
}}
|
||||
const rawPushState = history.pushState.bind(history);
|
||||
const rawReplaceState = history.replaceState.bind(history);
|
||||
history.pushState = (state, title, url) => rawPushState(state, title, url ? allowedUrl(url) : location.href);
|
||||
@@ -722,6 +876,7 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
|
||||
}}, true);
|
||||
enforceLocation();
|
||||
purgeGuides();
|
||||
hideWeKnoraShell();
|
||||
let ready = false;
|
||||
function hasDetailShell() {{
|
||||
return Boolean(document.querySelector(".knowledge-layout") || document.querySelector(".document-header"));
|
||||
@@ -729,13 +884,17 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
|
||||
function markReady(force = false) {{
|
||||
enforceLocation();
|
||||
purgeGuides();
|
||||
hideWeKnoraShell();
|
||||
if (ready) return;
|
||||
if (!force && (location.pathname !== allowedPath || !hasDetailShell())) return;
|
||||
ready = true;
|
||||
root.classList.add("deerflow-weknora-ready");
|
||||
}}
|
||||
const readyObserver = new MutationObserver(() => markReady(false));
|
||||
readyObserver.observe(document.documentElement, {{ childList: true, subtree: true }});
|
||||
const readyObserver = new MutationObserver(() => {{
|
||||
markReady(false);
|
||||
refreshThemeSoon();
|
||||
}});
|
||||
readyObserver.observe(document.documentElement, {{ childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style", "data-theme", "theme-mode"] }});
|
||||
if (document.readyState === "complete") {{
|
||||
setTimeout(() => markReady(false), 180);
|
||||
}} else {{
|
||||
@@ -744,6 +903,7 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
|
||||
setInterval(() => {{
|
||||
enforceLocation();
|
||||
purgeGuides();
|
||||
hideWeKnoraShell();
|
||||
}}, 500);
|
||||
setTimeout(() => markReady(true), 2200);
|
||||
}})();
|
||||
|
||||
@@ -1045,6 +1045,10 @@ async def start_run(
|
||||
body_context["rag_mode_enabled"] = True
|
||||
if "llmwiki_knowledge_base_ids" not in body_context and llmwiki_rag.knowledge_base_ids:
|
||||
body_context["llmwiki_knowledge_base_ids"] = llmwiki_rag.knowledge_base_ids
|
||||
if not llmwiki_rag.knowledge_base_ids:
|
||||
existing_excluded = list(body_context.get("excluded_tools") or getattr(body, "excluded_tools", None) or [])
|
||||
if "llmwiki_search" not in existing_excluded:
|
||||
body_context["excluded_tools"] = [*existing_excluded, "llmwiki_search"]
|
||||
# Promote whitelisted top-level body fields into the context map so
|
||||
# callers can pass them either nested under ``context`` or as flat
|
||||
# top-level fields (``excluded_tools``, ``skill_stop_names``, …).
|
||||
|
||||
@@ -491,7 +491,7 @@ llmwiki:
|
||||
api_base_url: "http://119.254.86.251:8088/deerflow-api"
|
||||
web_base_url: "http://119.254.86.251:8088"
|
||||
admin_email: "lqq@qq.com"
|
||||
admin_password: "$WEKNORA_ADMIN_PASSWORD"
|
||||
admin_password: "1qaz@WSX"
|
||||
|
||||
log_system:
|
||||
enabled: true
|
||||
|
||||
+22
-8
@@ -2,11 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING
|
||||
import os
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from deerflow.config.agents_config import load_agent_soul
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
@@ -22,7 +23,20 @@ logger = logging.getLogger(__name__)
|
||||
# Truthy strings for boolean env vars. Mirrors the convention used by
|
||||
# DEER_FLOW_AUTH_DISABLED so operators don't have to learn yet another
|
||||
# spelling for "yes, turn this on".
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on", "y"})
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on", "y"})
|
||||
_BEIJING_TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def _current_time_context() -> str:
|
||||
now = datetime.now(_BEIJING_TZ)
|
||||
return (
|
||||
f"\n<current_datetime timezone=\"Asia/Shanghai\">{now.strftime('%Y-%m-%d %H:%M:%S, %A')}</current_datetime>"
|
||||
"\n<time_grounding>"
|
||||
"When interpreting relative dates such as 今天, 昨天, 近几天, 最近, this week, or last month, "
|
||||
"use current_datetime as the only reference point. Before calling web_search, rewrite relative "
|
||||
"time expressions into concrete dates or date ranges in the query; never infer a stale year."
|
||||
"</time_grounding>"
|
||||
)
|
||||
|
||||
|
||||
def _is_offline_mode() -> bool:
|
||||
@@ -761,7 +775,7 @@ RAG/reference display mode is enabled.
|
||||
acp_section=acp_and_mounts_section,
|
||||
)
|
||||
|
||||
prompt = prompt + rag_mode_section + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
|
||||
prompt = prompt + rag_mode_section + _current_time_context()
|
||||
return prompt
|
||||
|
||||
|
||||
@@ -1359,7 +1373,7 @@ When a search, knowledge, skill, or MCP tool returns a result list:
|
||||
acp_section=acp_and_mounts_section,
|
||||
)
|
||||
|
||||
prompt = prompt + rag_mode_section + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
|
||||
prompt = prompt + rag_mode_section + _current_time_context()
|
||||
|
||||
if is_notebook_mode:
|
||||
source_ids = notebook_source_ids or []
|
||||
|
||||
+64
-14
@@ -6,8 +6,10 @@ import json
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
from langchain.tools import tool
|
||||
@@ -16,6 +18,12 @@ from deerflow.config import get_app_config
|
||||
from deerflow.config.tool_config import ToolConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_BEIJING_TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def _current_time_hint() -> str:
|
||||
now = datetime.now(_BEIJING_TZ)
|
||||
return now.strftime("%Y-%m-%d %H:%M:%S, %A, Asia/Shanghai")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -87,6 +95,23 @@ def _text(value: Any) -> str:
|
||||
return str(value)
|
||||
|
||||
|
||||
def _metadata(item: dict[str, Any]) -> dict[str, Any]:
|
||||
value = item.get("metadata")
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _first_text(*values: Any) -> str:
|
||||
for value in values:
|
||||
text = _text(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def _looks_like_url(value: str) -> bool:
|
||||
return value.startswith(("http://", "https://"))
|
||||
|
||||
|
||||
def _error_result(query: str, message: str) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
@@ -100,10 +125,13 @@ def _error_result(query: str, message: str) -> str:
|
||||
|
||||
|
||||
def _normalize_results(data: Any, settings: ConfigurableSearchSettings) -> list[dict[str, str]]:
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("search API response must be a JSON object")
|
||||
if isinstance(data, list):
|
||||
raw_results = data
|
||||
elif isinstance(data, dict):
|
||||
raw_results = data.get("results")
|
||||
else:
|
||||
raise ValueError("search API response must be a JSON object or array")
|
||||
|
||||
raw_results = data.get("results")
|
||||
if not isinstance(raw_results, list):
|
||||
raise ValueError("search API response field 'results' must be an array")
|
||||
|
||||
@@ -113,8 +141,21 @@ def _normalize_results(data: Any, settings: ConfigurableSearchSettings) -> list[
|
||||
logger.warning("Ignoring non-object search result: %r", item)
|
||||
continue
|
||||
|
||||
rec_uuid = _text(item.get("recUuid")).strip()
|
||||
original_url = _text(item.get("url")).strip()
|
||||
metadata = _metadata(item)
|
||||
rec_uuid = _first_text(item.get("recUuid"), item.get("rec_uuid"), metadata.get("recUuid"), metadata.get("rec_uuid"))
|
||||
source = _first_text(item.get("source"), item.get("source1"), metadata.get("source"), metadata.get("source1"))
|
||||
original_url = _first_text(
|
||||
item.get("url"),
|
||||
item.get("link"),
|
||||
item.get("href"),
|
||||
item.get("source_url"),
|
||||
metadata.get("url"),
|
||||
metadata.get("link"),
|
||||
metadata.get("href"),
|
||||
metadata.get("source_url"),
|
||||
)
|
||||
if not original_url and _looks_like_url(source):
|
||||
original_url = source
|
||||
result_url = original_url
|
||||
if rec_uuid and settings.result_url_template:
|
||||
result_url = settings.result_url_template.replace(
|
||||
@@ -122,14 +163,19 @@ def _normalize_results(data: Any, settings: ConfigurableSearchSettings) -> list[
|
||||
quote(rec_uuid, safe=""),
|
||||
)
|
||||
|
||||
normalized.append(
|
||||
{
|
||||
"recUuid": rec_uuid,
|
||||
"content": _text(item.get("content")),
|
||||
"title": _text(item.get("title")),
|
||||
"url": result_url,
|
||||
}
|
||||
)
|
||||
normalized_item = {
|
||||
"recUuid": rec_uuid,
|
||||
"content": _first_text(item.get("content"), item.get("page_content"), metadata.get("content")),
|
||||
"title": _first_text(item.get("title"), item.get("m_title"), metadata.get("m_title")),
|
||||
"url": result_url,
|
||||
}
|
||||
if source:
|
||||
normalized_item["source"] = source
|
||||
publish_time = _first_text(item.get("time"), item.get("publish_time"), item.get("m_publish"), metadata.get("m_publish"))
|
||||
if publish_time:
|
||||
normalized_item["time"] = publish_time
|
||||
normalized_item["publish_time"] = publish_time
|
||||
normalized.append(normalized_item)
|
||||
return normalized
|
||||
|
||||
|
||||
@@ -193,10 +239,14 @@ async def web_search_tool(query: str) -> str:
|
||||
|
||||
Args:
|
||||
query: Focused search keywords or a natural-language retrieval question.
|
||||
Interpret relative time expressions against the current time in
|
||||
Asia/Shanghai, and include concrete dates/date ranges in the query
|
||||
when the user asks about recent periods.
|
||||
"""
|
||||
|
||||
try:
|
||||
settings = ConfigurableSearchSettings.from_tool_config(get_app_config().get_tool_config("web_search"))
|
||||
except ValueError as exc:
|
||||
return _error_result(query, str(exc))
|
||||
return await execute_search(query, settings)
|
||||
grounded_query = f"{query.strip()}\n当前时间: {_current_time_hint()}" if query.strip() else query
|
||||
return await execute_search(grounded_query, settings)
|
||||
+14
-17
@@ -71,8 +71,8 @@ async def llmwiki_search_tool(
|
||||
) -> str:
|
||||
"""Search the LLMWiki knowledge selected for this conversation.
|
||||
|
||||
Use this tool when an answer can benefit from the user's managed knowledge
|
||||
bases or the shared public LLMWiki. It only retrieves passages; it cannot
|
||||
Use this tool only when the current conversation explicitly selected one
|
||||
or more LLMWiki knowledge bases. It only retrieves passages; it cannot
|
||||
upload, edit, delete, publish or change permissions.
|
||||
|
||||
Args:
|
||||
@@ -90,26 +90,23 @@ async def llmwiki_search_tool(
|
||||
selected_ids = selected_knowledge_base_ids(context)
|
||||
allowed_ids = _agent_allowed_ids(context)
|
||||
if selected_ids is None:
|
||||
selected_ids = allowed_ids
|
||||
return "No LLMWiki knowledge base is selected for this conversation."
|
||||
elif allowed_ids is not None and not set(selected_ids).issubset(set(allowed_ids)):
|
||||
return "The conversation requested knowledge outside this agent's configured LLMWiki scope."
|
||||
|
||||
if selected_ids == []:
|
||||
return "No LLMWiki knowledge base is selected for this conversation."
|
||||
if selected_ids is not None:
|
||||
rows = []
|
||||
for mapping_id in selected_ids:
|
||||
row = await store.get_authorized(
|
||||
mapping_id,
|
||||
user_id,
|
||||
write=False,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
if row is None:
|
||||
return f"Knowledge base {mapping_id!r} is not available to the current user."
|
||||
rows.append(row)
|
||||
else:
|
||||
rows = await store.list_visible(user_id, scope="all", is_admin=False)
|
||||
rows = []
|
||||
for mapping_id in selected_ids:
|
||||
row = await store.get_authorized(
|
||||
mapping_id,
|
||||
user_id,
|
||||
write=False,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
if row is None:
|
||||
return f"Knowledge base {mapping_id!r} is not available to the current user."
|
||||
rows.append(row)
|
||||
if not rows:
|
||||
return "No authorized LLMWiki knowledge bases are available."
|
||||
|
||||
|
||||
+76
@@ -104,6 +104,82 @@ async def test_configurable_search_handles_empty_results() -> None:
|
||||
assert result == {"query": "nothing", "total_results": 0, "results": []}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configurable_search_accepts_root_array_results() -> None:
|
||||
async def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"recUuid": "root-array-record",
|
||||
"content": "direct array content",
|
||||
"title": "direct array title",
|
||||
"url": "https://original.example.test/root-array",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
result = json.loads(
|
||||
await execute_search(
|
||||
"array response",
|
||||
_settings(result_url_template=""),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"query": "array response",
|
||||
"total_results": 1,
|
||||
"results": [
|
||||
{
|
||||
"recUuid": "root-array-record",
|
||||
"content": "direct array content",
|
||||
"title": "direct array title",
|
||||
"url": "https://original.example.test/root-array",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configurable_search_normalizes_metadata_wrapped_results() -> None:
|
||||
async def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"page_content": "outer content",
|
||||
"metadata": {
|
||||
"m_title": "metadata title",
|
||||
"content": "metadata content",
|
||||
"source1": "https://source.example.test/article/1",
|
||||
"m_publish": "2026-08-13",
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
result = json.loads(
|
||||
await execute_search(
|
||||
"metadata response",
|
||||
_settings(result_url_template=""),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
)
|
||||
|
||||
assert result["results"] == [
|
||||
{
|
||||
"recUuid": "",
|
||||
"content": "outer content",
|
||||
"title": "metadata title",
|
||||
"source": "https://source.example.test/article/1",
|
||||
"time": "2026-08-13",
|
||||
"publish_time": "2026-08-13",
|
||||
"url": "https://source.example.test/article/1",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("response_factory", "expected_error"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -128,6 +129,18 @@ def test_conversation_selection_is_deduplicated_and_absence_is_distinct() -> Non
|
||||
assert selected_knowledge_base_ids({"llmwiki_knowledge_base_ids": ["kb-one", "kb-one", "", "kb-two"]}) == ["kb-one", "kb-two"]
|
||||
|
||||
|
||||
def test_llmwiki_rag_only_uses_explicit_conversation_selection() -> None:
|
||||
from app.gateway.llmwiki_rag import resolve_requested_llmwiki_knowledge_base_ids
|
||||
|
||||
body = SimpleNamespace(
|
||||
assistant_id="agent-with-bound-kb",
|
||||
context={},
|
||||
metadata={"llmwiki_knowledge_base_ids": ["kb-one", "kb-one", "", "kb-two"]},
|
||||
)
|
||||
|
||||
assert resolve_requested_llmwiki_knowledge_base_ids(body) == ["kb-one", "kb-two"]
|
||||
|
||||
|
||||
def test_weknora_frame_bootstrap_accepts_query_token_only_for_frame_route() -> None:
|
||||
from app.gateway.deps import get_request_access_token
|
||||
|
||||
@@ -890,6 +903,28 @@ def test_weknora_iframe_proxy_is_fallback_after_deerflow_api_routes() -> None:
|
||||
assert _first_gateway_match("GET", "/platform/knowledge-bases/remote-kb") == "/platform/{proxied_path:path}"
|
||||
|
||||
|
||||
def test_weknora_iframe_hides_current_outer_sidebar_shell() -> None:
|
||||
from app.gateway.routers import llmwiki as llmwiki_router
|
||||
|
||||
ctx = llmwiki_router.WeKnoraEmbedContext(
|
||||
mapping_id="local-kb",
|
||||
weknora_id="remote-kb",
|
||||
user_id="user-a",
|
||||
is_admin=False,
|
||||
can_write=False,
|
||||
exp=4_102_444_800,
|
||||
)
|
||||
injection = llmwiki_router._weknora_embed_injection(
|
||||
allowed_path="/platform/knowledge-bases/remote-kb",
|
||||
tab="wiki",
|
||||
session={},
|
||||
ctx=ctx,
|
||||
)
|
||||
|
||||
assert "#app > .main > .aside_box" in injection
|
||||
assert '".main > .aside_box"' in injection
|
||||
|
||||
|
||||
def test_weknora_iframe_filters_kb_list_to_current_mapping() -> None:
|
||||
from app.gateway.routers import llmwiki as llmwiki_router
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
# Direct runtime packages layered into the prebuilt offline backend image.
|
||||
# Keep these four pins synchronized with start_backend.sh.
|
||||
# Keep the aiomysql/PyMySQL/SQLAlchemy compatibility bundle synchronized with
|
||||
# start_backend.sh. Installing aiomysql alone can leave the prebuilt image's
|
||||
# older SQLAlchemy adapter with an incompatible ping(reconnect) signature.
|
||||
croniter==6.2.2
|
||||
hindsight-client==0.6.2
|
||||
redis==5.2.1
|
||||
aiomysql==0.3.2
|
||||
SQLAlchemy==2.0.51
|
||||
|
||||
# Fully pinned dependency closure for CPython 3.12 on Linux. Keeping the
|
||||
# transitive packages here makes a newly generated wheelhouse reproducible.
|
||||
@@ -15,6 +18,7 @@ annotated-types==0.7.0
|
||||
async-timeout==5.0.1
|
||||
attrs==26.1.0
|
||||
frozenlist==1.8.0
|
||||
greenlet==3.2.5
|
||||
idna==3.18
|
||||
multidict==6.7.1
|
||||
propcache==0.5.2
|
||||
|
||||
@@ -61,6 +61,8 @@ require_wheel 'hindsight_client-0.6.2-*.whl'
|
||||
require_wheel 'redis-5.2.1-*.whl'
|
||||
require_wheel 'aiomysql-0.3.2-*.whl'
|
||||
require_wheel 'pymysql-1.2.0-*.whl'
|
||||
require_wheel 'sqlalchemy-2.0.51-*.whl'
|
||||
require_wheel 'greenlet-3.2.5-*.whl'
|
||||
|
||||
# Resolve the same requirements a second time using only wheelhouse. This
|
||||
# catches a missing transitive dependency before the bundle reaches offline.
|
||||
|
||||
@@ -131,9 +131,9 @@ if [ "${HINDSIGHT_INSTALL_DEPS:-1}" != "0" ]; then
|
||||
}
|
||||
fi
|
||||
if [ "${AIOMYSQL_INSTALL_DEPS:-1}" != "0" ]; then
|
||||
uv run --no-sync python -c "import aiomysql" >/dev/null 2>&1 || {
|
||||
uv run --no-sync python -c "import aiomysql, pymysql; import inspect; from sqlalchemy.dialects.mysql.aiomysql import AsyncAdapt_aiomysql_connection; assert inspect.signature(AsyncAdapt_aiomysql_connection.ping).parameters.get(\"reconnect\").default is False" >/dev/null 2>&1 || {
|
||||
PYTHON_BIN="$(uv run --no-sync python -c "import sys; print(sys.executable)")"
|
||||
uv pip install --python "${PYTHON_BIN}" --no-index --find-links /app/wheelhouse ${AIOMYSQL_PIP_PACKAGES:-aiomysql==0.3.2}
|
||||
uv pip install --python "${PYTHON_BIN}" --no-index --find-links /app/wheelhouse ${AIOMYSQL_PIP_PACKAGES:-aiomysql==0.3.2 PyMySQL==1.2.0 SQLAlchemy==2.0.51}
|
||||
}
|
||||
fi
|
||||
uv run --no-sync python -c "import redis" >/dev/null 2>&1 || uv pip install --python "$(uv run --no-sync python -c "import sys; print(sys.executable)")" --no-index --find-links /app/wheelhouse ${REDIS_PIP_PACKAGES:-redis==5.2.1}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$RootDir = Resolve-Path (Join-Path $ScriptDir "..")
|
||||
|
||||
$BackendDir = Join-Path $RootDir "offline-backend-20260512\backend"
|
||||
$FrontendDir = Join-Path $RootDir "frontend-web"
|
||||
$RuntimeDir = Join-Path $RootDir ".runtime"
|
||||
$LogDir = Join-Path $RuntimeDir "logs"
|
||||
$PidDir = Join-Path $RuntimeDir "pids"
|
||||
|
||||
$BackendHost = if ($env:BACKEND_HOST) { $env:BACKEND_HOST } else { "0.0.0.0" }
|
||||
$BackendPort = if ($env:BACKEND_PORT) { $env:BACKEND_PORT } else { "8001" }
|
||||
$FrontendHost = if ($env:FRONTEND_HOST) { $env:FRONTEND_HOST } else { "0.0.0.0" }
|
||||
$FrontendPort = if ($env:FRONTEND_PORT) { $env:FRONTEND_PORT } else { "5174" }
|
||||
|
||||
$HostIp = if ($env:HOST_IP) { $env:HOST_IP } else { "127.0.0.1" }
|
||||
$BackendPublicBaseUrl = if ($env:BACKEND_PUBLIC_BASE_URL) { $env:BACKEND_PUBLIC_BASE_URL } else { "http://${HostIp}:${BackendPort}" }
|
||||
$FrontendPublicBaseUrl = if ($env:FRONTEND_PUBLIC_BASE_URL) { $env:FRONTEND_PUBLIC_BASE_URL } else { "http://${HostIp}:${FrontendPort}" }
|
||||
$GatewayCorsOrigins = if ($env:GATEWAY_CORS_ORIGINS) {
|
||||
$env:GATEWAY_CORS_ORIGINS
|
||||
} else {
|
||||
"http://localhost:${FrontendPort},http://127.0.0.1:${FrontendPort},http://${HostIp}:${FrontendPort},http://localhost:8080,http://127.0.0.1:8080"
|
||||
}
|
||||
|
||||
$BackendPython = Join-Path $BackendDir ".venv\Scripts\python.exe"
|
||||
$BackendVendorDir = if ($env:BACKEND_VENDOR_DIR) { $env:BACKEND_VENDOR_DIR } else { Join-Path $BackendDir ".deer-flow\python-vendor" }
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $LogDir, $PidDir | Out-Null
|
||||
|
||||
function Test-PidRunning {
|
||||
param([string]$PidFile)
|
||||
if (-not (Test-Path -LiteralPath $PidFile)) { return $false }
|
||||
$rawPid = (Get-Content -LiteralPath $PidFile -ErrorAction SilentlyContinue | Select-Object -First 1)
|
||||
$processId = 0
|
||||
if (-not [int]::TryParse([string]$rawPid, [ref]$processId)) { return $false }
|
||||
return [bool](Get-Process -Id $processId -ErrorAction SilentlyContinue)
|
||||
}
|
||||
|
||||
function Stop-ProcessTree {
|
||||
param([int]$ProcessId)
|
||||
$children = Get-CimInstance Win32_Process -Filter "ParentProcessId=$ProcessId" -ErrorAction SilentlyContinue
|
||||
foreach ($child in $children) {
|
||||
Stop-ProcessTree -ProcessId ([int]$child.ProcessId)
|
||||
}
|
||||
$proc = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue
|
||||
if ($proc) {
|
||||
Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function ConvertTo-PSLiteral {
|
||||
param([string]$Value)
|
||||
return "'" + ($Value -replace "'", "''") + "'"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
. "$PSScriptRoot\env.ps1"
|
||||
|
||||
& "$PSScriptRoot\stop-all.ps1"
|
||||
& "$PSScriptRoot\start-all.ps1"
|
||||
@@ -0,0 +1,21 @@
|
||||
. "$PSScriptRoot\env.ps1"
|
||||
|
||||
& "$PSScriptRoot\start-all.ps1"
|
||||
|
||||
$deadline = (Get-Date).AddSeconds(60)
|
||||
$url = "$FrontendPublicBaseUrl/"
|
||||
Write-Host "Waiting for frontend: $url"
|
||||
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
try {
|
||||
$response = Invoke-WebRequest -UseBasicParsing -Uri $url -TimeoutSec 2
|
||||
if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 500) {
|
||||
Write-Host "Frontend is ready: $url"
|
||||
exit 0
|
||||
}
|
||||
} catch {
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
|
||||
throw "Timed out waiting for frontend: $url"
|
||||
@@ -0,0 +1,4 @@
|
||||
. "$PSScriptRoot\env.ps1"
|
||||
|
||||
& "$PSScriptRoot\start-backend.ps1"
|
||||
& "$PSScriptRoot\start-frontend.ps1"
|
||||
@@ -0,0 +1,42 @@
|
||||
. "$PSScriptRoot\env.ps1"
|
||||
|
||||
$PidFile = Join-Path $PidDir "backend.pid"
|
||||
$LogFile = Join-Path $LogDir "backend.log"
|
||||
|
||||
if (Test-PidRunning $PidFile) {
|
||||
$existingPid = Get-Content -LiteralPath $PidFile | Select-Object -First 1
|
||||
Write-Host "Backend is already running (pid $existingPid)."
|
||||
Write-Host "URL: $BackendPublicBaseUrl"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BackendPython)) {
|
||||
throw "Backend Python not found: $BackendPython. Run uv sync in offline-backend-20260512/backend first."
|
||||
}
|
||||
|
||||
Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $BackendVendorDir | Out-Null
|
||||
|
||||
Write-Host "Starting backend..."
|
||||
Write-Host "Log: $LogFile"
|
||||
|
||||
$RunnerFile = Join-Path $RuntimeDir "run-backend.ps1"
|
||||
$runner = @"
|
||||
`$ErrorActionPreference = "Continue"
|
||||
Set-Location -LiteralPath $(ConvertTo-PSLiteral $BackendDir)
|
||||
`$env:GATEWAY_CORS_ORIGINS = $(ConvertTo-PSLiteral $GatewayCorsOrigins)
|
||||
`$env:PYTHONPATH = $(ConvertTo-PSLiteral ".;$BackendVendorDir")
|
||||
if (-not `$env:WEKNORA_ADMIN_PASSWORD) { `$env:WEKNORA_ADMIN_PASSWORD = "1qaz@WSX" }
|
||||
cmd.exe /c "`"$(($BackendPython -replace '"', '\"'))`" -m uvicorn app.gateway.app:app --host $(($BackendHost -replace '"', '\"')) --port $(($BackendPort -replace '"', '\"')) --reload > `"$(($LogFile -replace '"', '\"'))`" 2>&1"
|
||||
"@
|
||||
Set-Content -LiteralPath $RunnerFile -Value $runner -Encoding UTF8
|
||||
|
||||
$proc = Start-Process -FilePath "powershell.exe" -ArgumentList @(
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy", "Bypass",
|
||||
"-File", $RunnerFile
|
||||
) -WindowStyle Hidden -PassThru
|
||||
|
||||
Set-Content -LiteralPath $PidFile -Value $proc.Id
|
||||
Write-Host "Backend started (pid $($proc.Id))."
|
||||
Write-Host "URL: $BackendPublicBaseUrl"
|
||||
@@ -0,0 +1,41 @@
|
||||
. "$PSScriptRoot\env.ps1"
|
||||
|
||||
$PidFile = Join-Path $PidDir "frontend.pid"
|
||||
$LogFile = Join-Path $LogDir "frontend.log"
|
||||
|
||||
if (Test-PidRunning $PidFile) {
|
||||
$existingPid = Get-Content -LiteralPath $PidFile | Select-Object -First 1
|
||||
Write-Host "Frontend is already running (pid $existingPid)."
|
||||
Write-Host "URL: $FrontendPublicBaseUrl"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$NpmCmd = (Get-Command npm.cmd -ErrorAction SilentlyContinue).Source
|
||||
if (-not $NpmCmd) {
|
||||
throw "npm.cmd was not found in PATH. Install Node.js or add npm.cmd to PATH."
|
||||
}
|
||||
|
||||
Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host "Starting frontend..."
|
||||
Write-Host "Log: $LogFile"
|
||||
|
||||
$RunnerFile = Join-Path $RuntimeDir "run-frontend.ps1"
|
||||
$runner = @"
|
||||
`$ErrorActionPreference = "Continue"
|
||||
Set-Location -LiteralPath $(ConvertTo-PSLiteral $FrontendDir)
|
||||
`$env:VITE_BACKEND_BASE_URL = $(ConvertTo-PSLiteral $BackendPublicBaseUrl)
|
||||
`$env:VITE_LANGGRAPH_BASE_URL = $(ConvertTo-PSLiteral "$BackendPublicBaseUrl/api")
|
||||
& $(ConvertTo-PSLiteral $NpmCmd) run dev -- --host $(ConvertTo-PSLiteral $FrontendHost) --port $(ConvertTo-PSLiteral $FrontendPort) *> $(ConvertTo-PSLiteral $LogFile)
|
||||
"@
|
||||
Set-Content -LiteralPath $RunnerFile -Value $runner -Encoding UTF8
|
||||
|
||||
$proc = Start-Process -FilePath "powershell.exe" -ArgumentList @(
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy", "Bypass",
|
||||
"-File", $RunnerFile
|
||||
) -WindowStyle Hidden -PassThru
|
||||
|
||||
Set-Content -LiteralPath $PidFile -Value $proc.Id
|
||||
Write-Host "Frontend started (pid $($proc.Id))."
|
||||
Write-Host "URL: $FrontendPublicBaseUrl"
|
||||
@@ -0,0 +1,18 @@
|
||||
. "$PSScriptRoot\env.ps1"
|
||||
|
||||
$backendPidFile = Join-Path $PidDir "backend.pid"
|
||||
$frontendPidFile = Join-Path $PidDir "frontend.pid"
|
||||
|
||||
if (Test-PidRunning $backendPidFile) {
|
||||
$pidValue = Get-Content -LiteralPath $backendPidFile | Select-Object -First 1
|
||||
Write-Host "Backend: running (pid $pidValue) $BackendPublicBaseUrl"
|
||||
} else {
|
||||
Write-Host "Backend: stopped"
|
||||
}
|
||||
|
||||
if (Test-PidRunning $frontendPidFile) {
|
||||
$pidValue = Get-Content -LiteralPath $frontendPidFile | Select-Object -First 1
|
||||
Write-Host "Frontend: running (pid $pidValue) $FrontendPublicBaseUrl"
|
||||
} else {
|
||||
Write-Host "Frontend: stopped"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
. "$PSScriptRoot\env.ps1"
|
||||
|
||||
& "$PSScriptRoot\stop-frontend.ps1"
|
||||
& "$PSScriptRoot\stop-backend.ps1"
|
||||
@@ -0,0 +1,20 @@
|
||||
. "$PSScriptRoot\env.ps1"
|
||||
|
||||
$PidFile = Join-Path $PidDir "backend.pid"
|
||||
if (-not (Test-Path -LiteralPath $PidFile)) {
|
||||
Write-Host "Backend is not running (no pid file)."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$rawPid = Get-Content -LiteralPath $PidFile | Select-Object -First 1
|
||||
$processId = 0
|
||||
if (-not [int]::TryParse([string]$rawPid, [ref]$processId) -or -not (Get-Process -Id $processId -ErrorAction SilentlyContinue)) {
|
||||
Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "Backend is not running (stale pid file removed)."
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "Stopping backend (pid $processId)..."
|
||||
Stop-ProcessTree -ProcessId $processId
|
||||
Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "Backend stopped."
|
||||
@@ -0,0 +1,20 @@
|
||||
. "$PSScriptRoot\env.ps1"
|
||||
|
||||
$PidFile = Join-Path $PidDir "frontend.pid"
|
||||
if (-not (Test-Path -LiteralPath $PidFile)) {
|
||||
Write-Host "Frontend is not running (no pid file)."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$rawPid = Get-Content -LiteralPath $PidFile | Select-Object -First 1
|
||||
$processId = 0
|
||||
if (-not [int]::TryParse([string]$rawPid, [ref]$processId) -or -not (Get-Process -Id $processId -ErrorAction SilentlyContinue)) {
|
||||
Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "Frontend is not running (stale pid file removed)."
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "Stopping frontend (pid $processId)..."
|
||||
Stop-ProcessTree -ProcessId $processId
|
||||
Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "Frontend stopped."
|
||||
Reference in new issue
Block a user