fix: improve llmwiki search integration

This commit is contained in:
Administrator committed 2026-08-13 16:03:08 +08:00
1 parent 4809ee7db3
commit 56044d9cb7
20 files changed
+504 -66

No files matched your search

+9 -8
View File
@@ -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)"
+52
View File
@@ -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``, …).
@@ -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 []
@@ -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)
@@ -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."
@@ -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
+55
View File
@@ -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 "'", "''") + "'"
}
+4
View File
@@ -0,0 +1,4 @@
. "$PSScriptRoot\env.ps1"
& "$PSScriptRoot\stop-all.ps1"
& "$PSScriptRoot\start-all.ps1"
+21
View File
@@ -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"
+4
View File
@@ -0,0 +1,4 @@
. "$PSScriptRoot\env.ps1"
& "$PSScriptRoot\start-backend.ps1"
& "$PSScriptRoot\start-frontend.ps1"
+42
View File
@@ -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"
+41
View File
@@ -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"
+18
View File
@@ -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"
}
+4
View File
@@ -0,0 +1,4 @@
. "$PSScriptRoot\env.ps1"
& "$PSScriptRoot\stop-frontend.ps1"
& "$PSScriptRoot\stop-backend.ps1"
+20
View File
@@ -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."
+20
View File
@@ -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."