From 56044d9cb7352beb30d0cc97b9b905d6ef66b5e2 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 13 Aug 2026 16:03:08 +0800 Subject: [PATCH] fix: improve llmwiki search integration --- .vscode/launch.json | 17 ++-- .vscode/tasks.json | 52 +++++++++++++ .../workspace/messages/message-group.tsx | 36 +++++---- .../backend/app/gateway/llmwiki_rag.py | 4 +- .../backend/app/gateway/services.py | 4 + .../deerflow/agents/lead_agent/prompt.py | 30 +++++-- .../community/configurable_search/tools.py | 78 +++++++++++++++---- .../tools/builtins/llmwiki_search_tool.py | 31 ++++---- .../tests/test_configurable_web_search.py | 76 ++++++++++++++++++ .../backend/tests/test_llmwiki_weknora.py | 13 ++++ scripts/env.ps1 | 55 +++++++++++++ scripts/restart-all.ps1 | 4 + scripts/start-all-and-wait-frontend.ps1 | 21 +++++ scripts/start-all.ps1 | 4 + scripts/start-backend.ps1 | 42 ++++++++++ scripts/start-frontend.ps1 | 41 ++++++++++ scripts/status.ps1 | 18 +++++ scripts/stop-all.ps1 | 4 + scripts/stop-backend.ps1 | 20 +++++ scripts/stop-frontend.ps1 | 20 +++++ 20 files changed, 504 insertions(+), 66 deletions(-) create mode 100644 scripts/env.ps1 create mode 100644 scripts/restart-all.ps1 create mode 100644 scripts/start-all-and-wait-frontend.ps1 create mode 100644 scripts/start-all.ps1 create mode 100644 scripts/start-backend.ps1 create mode 100644 scripts/start-frontend.ps1 create mode 100644 scripts/status.ps1 create mode 100644 scripts/stop-all.ps1 create mode 100644 scripts/stop-backend.ps1 create mode 100644 scripts/stop-frontend.ps1 diff --git a/.vscode/launch.json b/.vscode/launch.json index daafb653..cdd14238 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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)" diff --git a/.vscode/tasks.json b/.vscode/tasks.json index bb4aff2c..b8a70d8f 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -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", diff --git a/frontend-web/src/components/workspace/messages/message-group.tsx b/frontend-web/src/components/workspace/messages/message-group.tsx index b963fd6d..517f9d4f 100644 --- a/frontend-web/src/components/workspace/messages/message-group.tsx +++ b/frontend-web/src/components/workspace/messages/message-group.tsx @@ -295,17 +295,19 @@ function ToolCall({ ); } - 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 ( - + 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 ( + {searchItems.length > 0 && ( )} @@ -520,12 +522,12 @@ function ToolCall({ icon={WrenchIcon} > ); - } -} - -interface GenericCoTStep { - id?: string; - messageId?: string; + } +} + +interface GenericCoTStep { + id?: string; + messageId?: string; type: T; } diff --git a/offline-backend-20260512/backend/app/gateway/llmwiki_rag.py b/offline-backend-20260512/backend/app/gateway/llmwiki_rag.py index 7369e2ff..03c857d1 100644 --- a/offline-backend-20260512/backend/app/gateway/llmwiki_rag.py +++ b/offline-backend-20260512/backend/app/gateway/llmwiki_rag.py @@ -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: diff --git a/offline-backend-20260512/backend/app/gateway/services.py b/offline-backend-20260512/backend/app/gateway/services.py index 4eeb94cd..7ea95b7d 100644 --- a/offline-backend-20260512/backend/app/gateway/services.py +++ b/offline-backend-20260512/backend/app/gateway/services.py @@ -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``, …). diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 7b6d8221..394a0e29 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -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{now.strftime('%Y-%m-%d %H:%M:%S, %A')}" + "\n" + "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." + "" + ) 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{datetime.now().strftime('%Y-%m-%d, %A')}" + 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{datetime.now().strftime('%Y-%m-%d, %A')}" + prompt = prompt + rag_mode_section + _current_time_context() if is_notebook_mode: source_ids = notebook_source_ids or [] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/community/configurable_search/tools.py b/offline-backend-20260512/backend/packages/harness/deerflow/community/configurable_search/tools.py index 837a43a3..875d668c 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/community/configurable_search/tools.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/community/configurable_search/tools.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/llmwiki_search_tool.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/llmwiki_search_tool.py index f0bde69d..6630c2e3 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/llmwiki_search_tool.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/llmwiki_search_tool.py @@ -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." diff --git a/offline-backend-20260512/backend/packages/harness/tests/test_configurable_web_search.py b/offline-backend-20260512/backend/packages/harness/tests/test_configurable_web_search.py index 8abb4782..f17e0217 100644 --- a/offline-backend-20260512/backend/packages/harness/tests/test_configurable_web_search.py +++ b/offline-backend-20260512/backend/packages/harness/tests/test_configurable_web_search.py @@ -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"), diff --git a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py index 3bd4dcd5..3791c03b 100644 --- a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py +++ b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py @@ -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 diff --git a/scripts/env.ps1 b/scripts/env.ps1 new file mode 100644 index 00000000..68dcbb2e --- /dev/null +++ b/scripts/env.ps1 @@ -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 "'", "''") + "'" +} diff --git a/scripts/restart-all.ps1 b/scripts/restart-all.ps1 new file mode 100644 index 00000000..c9eb7c6f --- /dev/null +++ b/scripts/restart-all.ps1 @@ -0,0 +1,4 @@ +. "$PSScriptRoot\env.ps1" + +& "$PSScriptRoot\stop-all.ps1" +& "$PSScriptRoot\start-all.ps1" diff --git a/scripts/start-all-and-wait-frontend.ps1 b/scripts/start-all-and-wait-frontend.ps1 new file mode 100644 index 00000000..151ea87a --- /dev/null +++ b/scripts/start-all-and-wait-frontend.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" diff --git a/scripts/start-all.ps1 b/scripts/start-all.ps1 new file mode 100644 index 00000000..d5c9e517 --- /dev/null +++ b/scripts/start-all.ps1 @@ -0,0 +1,4 @@ +. "$PSScriptRoot\env.ps1" + +& "$PSScriptRoot\start-backend.ps1" +& "$PSScriptRoot\start-frontend.ps1" diff --git a/scripts/start-backend.ps1 b/scripts/start-backend.ps1 new file mode 100644 index 00000000..07a11ce6 --- /dev/null +++ b/scripts/start-backend.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" diff --git a/scripts/start-frontend.ps1 b/scripts/start-frontend.ps1 new file mode 100644 index 00000000..d069e0bc --- /dev/null +++ b/scripts/start-frontend.ps1 @@ -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" diff --git a/scripts/status.ps1 b/scripts/status.ps1 new file mode 100644 index 00000000..a03eccc6 --- /dev/null +++ b/scripts/status.ps1 @@ -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" +} diff --git a/scripts/stop-all.ps1 b/scripts/stop-all.ps1 new file mode 100644 index 00000000..4b49a993 --- /dev/null +++ b/scripts/stop-all.ps1 @@ -0,0 +1,4 @@ +. "$PSScriptRoot\env.ps1" + +& "$PSScriptRoot\stop-frontend.ps1" +& "$PSScriptRoot\stop-backend.ps1" diff --git a/scripts/stop-backend.ps1 b/scripts/stop-backend.ps1 new file mode 100644 index 00000000..3a0c3cad --- /dev/null +++ b/scripts/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." diff --git a/scripts/stop-frontend.ps1 b/scripts/stop-frontend.ps1 new file mode 100644 index 00000000..dbed2b0d --- /dev/null +++ b/scripts/stop-frontend.ps1 @@ -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."