fix: improve llmwiki search integration
This commit is contained in:
1 parent
4809ee7db3
commit
56044d9cb7
20 files changed
+504
-66
No files matched your search
Vendored
+9
-8
@@ -11,8 +11,7 @@
|
||||
"--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",
|
||||
@@ -24,6 +23,7 @@
|
||||
"WEKNORA_ADMIN_PASSWORD": "1qaz@WSX"
|
||||
},
|
||||
"console": "integratedTerminal",
|
||||
"redirectOutput": true,
|
||||
"justMyCode": false,
|
||||
"subProcess": true
|
||||
},
|
||||
@@ -31,7 +31,7 @@
|
||||
"name": "DeerFlow: Frontend Vite (5174)",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev -- --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",
|
||||
@@ -40,21 +40,22 @@
|
||||
"serverReadyAction": {
|
||||
"pattern": "Local:\\s+(https?://[^\\s]+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "debugWithChrome",
|
||||
"webRoot": "${workspaceFolder}/frontend-web"
|
||||
"action": "openExternally"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "DeerFlow: Open Frontend in Chrome",
|
||||
"type": "pwa-chrome",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"url": "http://127.0.0.1:5174/",
|
||||
"webRoot": "${workspaceFolder}/frontend-web"
|
||||
"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)",
|
||||
"preLaunchTask": "DeerFlow: Stop Full Stack",
|
||||
"configurations": [
|
||||
"DeerFlow: Backend FastAPI (8001)",
|
||||
"DeerFlow: Frontend Vite (5174)"
|
||||
|
||||
Vendored
+52
@@ -1,6 +1,58 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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``, …).
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -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