chore: initial commit of ai site platform
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
56
scripts/Apply-DockerMirrors.ps1
Normal file
56
scripts/Apply-DockerMirrors.ps1
Normal file
@@ -0,0 +1,56 @@
|
||||
# Apply China Docker Hub registry mirrors to Docker Desktop daemon.json.
|
||||
# Safe merge: keeps existing keys; only sets/extends registry-mirrors.
|
||||
# Compatible with Windows PowerShell 5.1.
|
||||
param(
|
||||
[switch]$NoRestartHint
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent $PSScriptRoot
|
||||
|
||||
$dockerDir = Join-Path $env:USERPROFILE ".docker"
|
||||
New-Item -ItemType Directory -Force -Path $dockerDir | Out-Null
|
||||
$daemonPath = Join-Path $dockerDir "daemon.json"
|
||||
|
||||
$wanted = @(
|
||||
"https://docker.m.daocloud.io",
|
||||
"https://docker.1ms.run",
|
||||
"https://docker.xuanyuan.me"
|
||||
)
|
||||
|
||||
$cfg = [ordered]@{}
|
||||
if (Test-Path -LiteralPath $daemonPath) {
|
||||
try {
|
||||
$raw = Get-Content -LiteralPath $daemonPath -Raw -Encoding UTF8
|
||||
if ($raw -and $raw.Trim()) {
|
||||
$obj = $raw | ConvertFrom-Json
|
||||
foreach ($p in $obj.PSObject.Properties) {
|
||||
$cfg[$p.Name] = $p.Value
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Host " !! existing daemon.json parse failed; rewriting mirrors" -ForegroundColor Yellow
|
||||
$cfg = [ordered]@{}
|
||||
}
|
||||
}
|
||||
|
||||
$existing = @()
|
||||
if ($cfg.Contains("registry-mirrors") -and $cfg["registry-mirrors"]) {
|
||||
$existing = @($cfg["registry-mirrors"] | ForEach-Object { [string]$_ })
|
||||
}
|
||||
$merged = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($m in ($wanted + $existing)) {
|
||||
if ($m -and -not $merged.Contains($m)) { [void]$merged.Add($m) }
|
||||
}
|
||||
$cfg["registry-mirrors"] = @($merged)
|
||||
|
||||
$json = $cfg | ConvertTo-Json -Depth 8
|
||||
Set-Content -LiteralPath $daemonPath -Value $json -Encoding UTF8
|
||||
Write-Host (" OK Docker mirrors -> {0}" -f $daemonPath) -ForegroundColor DarkGray
|
||||
foreach ($m in $merged) {
|
||||
Write-Host (" {0}" -f $m) -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
if (-not $NoRestartHint) {
|
||||
Write-Host " !! Restart Docker Desktop once if compose still cannot pull" -ForegroundColor Yellow
|
||||
}
|
||||
57
scripts/Ensure-Web.ps1
Normal file
57
scripts/Ensure-Web.ps1
Normal file
@@ -0,0 +1,57 @@
|
||||
# Ensure web frontend: npm install + optional dist build for Docker nginx.
|
||||
# Called automatically by start.ps1 / start-host.ps1 (no manual npm).
|
||||
param(
|
||||
[switch]$BuildDist
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent $PSScriptRoot
|
||||
if (-not (Test-Path (Join-Path $Root "web\package.json"))) {
|
||||
throw "web/package.json not found under repo root"
|
||||
}
|
||||
$WebDir = Join-Path $Root "web"
|
||||
|
||||
$npmCmd = Get-Command npm.cmd -ErrorAction SilentlyContinue
|
||||
if (-not $npmCmd) { $npmCmd = Get-Command npm -ErrorAction SilentlyContinue }
|
||||
if (-not $npmCmd) { throw "npm not found" }
|
||||
|
||||
Push-Location $WebDir
|
||||
try {
|
||||
if (-not (Test-Path "node_modules")) {
|
||||
Write-Host " npm install (web) ..." -ForegroundColor DarkGray
|
||||
& $npmCmd.Source install
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm install failed" }
|
||||
}
|
||||
|
||||
if (-not $BuildDist) {
|
||||
# Clear stale native exit code left by caller (e.g. docker compose failure)
|
||||
$global:LASTEXITCODE = 0
|
||||
return
|
||||
}
|
||||
|
||||
$distIndex = Join-Path $WebDir "dist\index.html"
|
||||
$needBuild = -not (Test-Path $distIndex)
|
||||
if (-not $needBuild) {
|
||||
$distTime = (Get-Item $distIndex).LastWriteTime
|
||||
$newer = Get-ChildItem -Path (Join-Path $WebDir "src") -Recurse -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.LastWriteTime -gt $distTime } |
|
||||
Select-Object -First 1
|
||||
if ($newer) { $needBuild = $true }
|
||||
$cfgNewer = Get-ChildItem -Path $WebDir -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -match '^(vite\.config|index\.html|package\.json)' -and $_.LastWriteTime -gt $distTime } |
|
||||
Select-Object -First 1
|
||||
if ($cfgNewer) { $needBuild = $true }
|
||||
}
|
||||
if ($needBuild) {
|
||||
Write-Host " npm run build (web dist, includes /#/preview) ..." -ForegroundColor DarkGray
|
||||
& $npmCmd.Source run build
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm run build failed" }
|
||||
if (-not (Test-Path $distIndex)) { throw "web/dist/index.html missing after build" }
|
||||
Write-Host " OK web/dist ready" -ForegroundColor DarkGray
|
||||
} else {
|
||||
Write-Host " OK web/dist up to date" -ForegroundColor DarkGray
|
||||
}
|
||||
$global:LASTEXITCODE = 0
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
173
scripts/Stop-Stack.ps1
Normal file
173
scripts/Stop-Stack.ps1
Normal file
@@ -0,0 +1,173 @@
|
||||
# Unified stack teardown: Docker containers (if any) + ALL native leftovers.
|
||||
# Used by stop.ps1 / restart.ps1 / start.ps1 / start-host.ps1.
|
||||
# Console messages are ASCII-only to avoid GBK mojibake.
|
||||
param(
|
||||
[switch]$Quiet
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
$Root = Split-Path -Parent $PSScriptRoot
|
||||
if (-not (Test-Path (Join-Path $Root "start.ps1"))) {
|
||||
# allow calling from repo root as -File scripts\Stop-Stack.ps1
|
||||
if (Test-Path (Join-Path $PSScriptRoot "..\start.ps1")) {
|
||||
$Root = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
}
|
||||
}
|
||||
Set-Location -LiteralPath $Root
|
||||
$LogDir = Join-Path $Root ".runtime\logs"
|
||||
|
||||
function Write-Info([string]$msg) {
|
||||
if (-not $Quiet) { Write-Host $msg }
|
||||
}
|
||||
|
||||
function Kill-Tree([int]$ProcessId) {
|
||||
if ($ProcessId -le 0) { return }
|
||||
# Skip self / critical system
|
||||
if ($ProcessId -eq $PID) { return }
|
||||
& taskkill.exe /F /T /PID $ProcessId 2>$null | Out-Null
|
||||
Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Kill-Port([int]$Port) {
|
||||
$pids = @()
|
||||
Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
$pids += [int]$_.OwningProcess
|
||||
}
|
||||
$net = & netstat.exe -ano 2>$null
|
||||
foreach ($line in $net) {
|
||||
if ($line -notmatch [regex]::Escape(":${Port}")) { continue }
|
||||
if ($line -notmatch "LISTENING") { continue }
|
||||
$parts = ($line -split "\s+") | Where-Object { $_ }
|
||||
$procId = 0
|
||||
if ([int]::TryParse($parts[-1], [ref]$procId) -and $procId -gt 0) {
|
||||
$pids += $procId
|
||||
}
|
||||
}
|
||||
foreach ($procId in ($pids | Select-Object -Unique)) {
|
||||
try {
|
||||
$proc = Get-Process -Id $procId -ErrorAction Stop
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
Write-Info (" free port {0} (pid={1} {2})" -f $Port, $procId, $proc.ProcessName)
|
||||
Kill-Tree $procId
|
||||
}
|
||||
}
|
||||
|
||||
function Kill-ByCommand([string]$ProcessName, [string]$Pattern) {
|
||||
Get-CimInstance Win32_Process -Filter ("Name='{0}'" -f $ProcessName) -ErrorAction SilentlyContinue | Where-Object {
|
||||
$_.CommandLine -and ($_.CommandLine -match $Pattern)
|
||||
} | ForEach-Object {
|
||||
Write-Info (" kill {0} pid={1}" -f $ProcessName, $_.ProcessId)
|
||||
Kill-Tree ([int]$_.ProcessId)
|
||||
}
|
||||
}
|
||||
|
||||
# ----- 1) Docker first (do NOT return; native may still be running) -----
|
||||
$dockerOk = $false
|
||||
try {
|
||||
$null = & docker info 2>&1
|
||||
if ($LASTEXITCODE -eq 0) { $dockerOk = $true }
|
||||
} catch { }
|
||||
|
||||
if ($dockerOk) {
|
||||
Write-Info "==> docker compose down --remove-orphans"
|
||||
& docker compose down --remove-orphans 2>$null
|
||||
# also stop any leftover named containers from older compose projects
|
||||
$names = @("ai-web-1", "ai-ai-1", "ai-gateway-1", "ai-platform-1", "ai-postgres-1")
|
||||
foreach ($n in $names) {
|
||||
& docker rm -f $n 2>$null | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# ----- 2) Watchdog first (otherwise it revives ai) -----
|
||||
Write-Info "==> stopping native processes..."
|
||||
$watchPidFile = Join-Path $LogDir "watchdog.pid"
|
||||
if (Test-Path $watchPidFile) {
|
||||
$wid = Get-Content $watchPidFile -ErrorAction SilentlyContinue
|
||||
if ($wid) {
|
||||
Write-Info (" kill watchdog pid={0}" -f $wid)
|
||||
Kill-Tree ([int]$wid)
|
||||
}
|
||||
Remove-Item $watchPidFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Kill-ByCommand "powershell.exe" "watch-services\.ps1"
|
||||
Kill-ByCommand "pwsh.exe" "watch-services\.ps1"
|
||||
Kill-ByCommand "powershell.exe" "start-host\.ps1"
|
||||
Kill-ByCommand "powershell.exe" "Restart-AiOnly|restart_ai_only|hard_restart"
|
||||
|
||||
# ----- 3) Pid files -----
|
||||
foreach ($name in @("ai-service.pid", "platform.pid", "gateway.pid", "web.pid", "watchdog.pid")) {
|
||||
$pf = Join-Path $LogDir $name
|
||||
if (Test-Path $pf) {
|
||||
$pidText = Get-Content $pf -ErrorAction SilentlyContinue
|
||||
if ($pidText) {
|
||||
Write-Info (" kill {0} -> {1}" -f $name, $pidText)
|
||||
Kill-Tree ([int]$pidText)
|
||||
}
|
||||
Remove-Item $pf -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# ----- 4) By process name / cmdline -----
|
||||
Get-Process -Name "platform", "gateway" -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
Write-Info (" kill {0} pid={1}" -f $_.ProcessName, $_.Id)
|
||||
Kill-Tree ([int]$_.Id)
|
||||
}
|
||||
|
||||
# uvicorn reloader = parent + workers
|
||||
Kill-ByCommand "python.exe" "uvicorn"
|
||||
Kill-ByCommand "python.exe" "app:app"
|
||||
Kill-ByCommand "python.exe" "shot_worker\.py"
|
||||
Kill-ByCommand "python.exe" "fidelity_loop|FIDELITY_SHOT"
|
||||
Kill-ByCommand "python.exe" "ai-service"
|
||||
|
||||
# vite / node tooling for this repo
|
||||
Kill-ByCommand "node.exe" "vite"
|
||||
Kill-ByCommand "node.exe" "esbuild"
|
||||
Kill-ByCommand "cmd.exe" "npm.*vite|vite\.js"
|
||||
|
||||
# Playwright Chromium leftovers from fidelity shots
|
||||
Kill-ByCommand "chrome.exe" "ms-playwright|playwright"
|
||||
Kill-ByCommand "chromium.exe" "ms-playwright|playwright"
|
||||
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
|
||||
$_.Name -match '^(chrome|chromium)\.exe$' -and $_.CommandLine -match 'ms-playwright|playwright_chromium'
|
||||
} | ForEach-Object {
|
||||
Write-Info (" kill playwright browser pid={0}" -f $_.ProcessId)
|
||||
Kill-Tree ([int]$_.ProcessId)
|
||||
}
|
||||
|
||||
# ----- 5) Ports (authoritative) -----
|
||||
foreach ($port in 8888, 8001, 8180, 5173) {
|
||||
Kill-Port $port
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
# second pass for stubborn reloaders
|
||||
Kill-ByCommand "python.exe" "uvicorn|app:app|shot_worker"
|
||||
Kill-ByCommand "node.exe" "vite"
|
||||
foreach ($port in 8888, 8001, 8180, 5173) {
|
||||
Kill-Port $port
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
|
||||
# ----- 6) Verify (only live processes count) -----
|
||||
$still = @()
|
||||
foreach ($port in 8888, 8001, 8180, 5173) {
|
||||
Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
$proc = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue
|
||||
if ($proc) {
|
||||
$still += (":{0} pid={1} {2}" -f $port, $_.OwningProcess, $proc.ProcessName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($still.Count -gt 0) {
|
||||
Write-Host "WARN still listening after stop:" -ForegroundColor Yellow
|
||||
$still | ForEach-Object { Write-Host (" {0}" -f $_) }
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Info "OK stopped (docker + native; ports 8888/8001/8180/5173 free)"
|
||||
exit 0
|
||||
229
scripts/e2e_flow.py
Normal file
229
scripts/e2e_flow.py
Normal file
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python3
|
||||
"""端到端:AI 生成 → publish → capsule → agent 解密列表 → import + 新能力冒烟"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "agent_sdk"))
|
||||
from client import AgentClient, decrypt_capsule # noqa: E402
|
||||
|
||||
PLATFORM = "http://127.0.0.1:8180"
|
||||
AI = "http://127.0.0.1:8180/ai"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
excel = ROOT / "ai-service" / "sample_inventory.xlsx"
|
||||
if not excel.exists():
|
||||
import subprocess
|
||||
|
||||
subprocess.check_call([sys.executable, str(ROOT / "ai-service" / "make_sample_excel.py")])
|
||||
|
||||
with httpx.Client(timeout=60.0) as http:
|
||||
# 网关无 token 应 401
|
||||
denied = http.get(f"{PLATFORM}/api/v1/audit/logs")
|
||||
assert denied.status_code == 401, denied.text
|
||||
print("gateway jwt deny ok")
|
||||
|
||||
tok = http.post(
|
||||
f"{PLATFORM}/api/v1/auth/login",
|
||||
json={"username": "demo", "password": "demo123"},
|
||||
)
|
||||
tok.raise_for_status()
|
||||
auth = tok.json()
|
||||
token, agent_key = auth["access_token"], auth["agent_key"]
|
||||
assert auth.get("role") in ("owner", "editor", "viewer"), auth
|
||||
print("login ok", auth.get("username"), "role", auth.get("role"), "tenant", auth.get("tenant_id"))
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# 对象存储
|
||||
up = http.post(
|
||||
f"{PLATFORM}/api/v1/storage",
|
||||
headers=headers,
|
||||
files={"file": ("note.txt", b"hello-storage", "text/plain")},
|
||||
)
|
||||
up.raise_for_status()
|
||||
upj = up.json()
|
||||
assert upj.get("key") and upj.get("url"), upj
|
||||
print("storage upload", upj["key"])
|
||||
|
||||
# 多模态(无 Key 时启发式)
|
||||
png = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
b"\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00"
|
||||
b"\x00\x01\x01\x00\x05\x18\xd8N\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
files = {
|
||||
"excel": (
|
||||
"sample_inventory.xlsx",
|
||||
excel.read_bytes(),
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
),
|
||||
"images": ("ui_list.png", png, "image/png"),
|
||||
}
|
||||
data = {"prompt": "根据库存表和截图生成可筛选库存列表", "storage_mode": "schema_per_app"}
|
||||
gen = http.post(f"{AI}/api/v1/apps/generate", data=data, files=files)
|
||||
gen.raise_for_status()
|
||||
gj = gen.json()
|
||||
draft = gj["draft"]
|
||||
warns = " ".join(gj.get("warnings") or [])
|
||||
vision_ok = (
|
||||
"看图" in warns
|
||||
or "截图" in warns
|
||||
or bool(draft.get("meta", {}).get("source", {}).get("vision_summary"))
|
||||
)
|
||||
assert vision_ok, warns
|
||||
slug = draft["meta"]["slug"]
|
||||
resource = draft["apis"]["resources"][0]["path"].lstrip("/")
|
||||
print("generate", slug, resource, "confidence", gj["confidence"])
|
||||
|
||||
# 唯一 slug,避免重复发布
|
||||
slug = f"{slug}_{int(time.time())}"
|
||||
draft["meta"]["slug"] = slug
|
||||
draft["apis"]["base_path"] = f"/api/v1/apps/{slug}"
|
||||
print("slug", slug)
|
||||
|
||||
pub = http.post(
|
||||
f"{PLATFORM}/api/v1/apps/{slug}/publish",
|
||||
headers=headers,
|
||||
json={"blueprint": draft},
|
||||
)
|
||||
if pub.status_code >= 400:
|
||||
print(pub.text)
|
||||
pub.raise_for_status()
|
||||
print("published", pub.json()["schema_name"], "memory_mode", pub.json()["memory_mode"])
|
||||
|
||||
# 审计
|
||||
audit = http.get(f"{PLATFORM}/api/v1/audit/logs", headers=headers, params={"page": 1})
|
||||
audit.raise_for_status()
|
||||
actions = [x.get("action") for x in audit.json().get("items") or []]
|
||||
assert any(a.startswith("publish.") for a in actions) or any(
|
||||
a == "storage.upload" for a in actions
|
||||
), actions
|
||||
print("audit actions sample", actions[:5])
|
||||
|
||||
cap = http.get(f"{PLATFORM}/api/v1/apps/{slug}/agent-capsule", headers=headers)
|
||||
cap.raise_for_status()
|
||||
capsule = cap.json()["capsule"]
|
||||
assert capsule.startswith("AJZ1."), capsule
|
||||
desc = decrypt_capsule(agent_key, capsule)
|
||||
assert "resources" in desc and desc["app_slug"] == slug
|
||||
print("capsule decrypted resources:", [r["name"] for r in desc["resources"]])
|
||||
assert "/api/v1/apps/" not in capsule
|
||||
|
||||
client = AgentClient(token, agent_key, capsule)
|
||||
created = client.create(
|
||||
resource,
|
||||
{
|
||||
"sku": "E2E-1",
|
||||
"product_name": "测试商品",
|
||||
"warehouse": "华东仓",
|
||||
"qty": 3,
|
||||
"unit_price": 9.9,
|
||||
"status": "在售",
|
||||
},
|
||||
)
|
||||
print("agent create id", created.get("id"))
|
||||
listed = client.list(resource, warehouse="华东仓")
|
||||
print("agent list total", listed.get("total"))
|
||||
|
||||
csv_body = "sku,product_name,warehouse,qty,unit_price,status\nE2E-2,导入商品,华南仓,5,19.9,在售\n"
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
import io
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["sku", "product_name", "warehouse", "qty", "unit_price", "status"])
|
||||
ws.append(["E2E-2", "导入商品", "华南仓", 5, 19.9, "在售"])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
files = {
|
||||
"file": (
|
||||
"imp.xlsx",
|
||||
buf.getvalue(),
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
}
|
||||
except Exception:
|
||||
files = {"file": ("imp.csv", csv_body.encode("utf-8"), "text/csv")}
|
||||
imp = http.post(
|
||||
f"{PLATFORM}/api/v1/apps/{slug}/{resource}/import",
|
||||
headers=headers,
|
||||
files=files,
|
||||
)
|
||||
imp.raise_for_status()
|
||||
print("import", imp.json())
|
||||
|
||||
ag = http.get(
|
||||
f"{PLATFORM}/api/v1/apps/{slug}/{resource}/aggregate",
|
||||
headers=headers,
|
||||
params={"group_by": "warehouse", "sum": "qty"},
|
||||
)
|
||||
ag.raise_for_status()
|
||||
print("aggregate", ag.json())
|
||||
|
||||
# database_per_app 冒烟
|
||||
data2 = {"prompt": "独立库库存台账", "storage_mode": "database_per_app"}
|
||||
gen2 = http.post(
|
||||
f"{AI}/api/v1/apps/generate",
|
||||
data=data2,
|
||||
files={
|
||||
"excel": (
|
||||
"sample_inventory.xlsx",
|
||||
excel.read_bytes(),
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
},
|
||||
)
|
||||
gen2.raise_for_status()
|
||||
draft2 = gen2.json()["draft"]
|
||||
assert draft2["storage"]["mode"] == "database_per_app"
|
||||
slug2 = f"dbapp_{int(time.time())}"
|
||||
draft2["meta"]["slug"] = slug2
|
||||
draft2["apis"]["base_path"] = f"/api/v1/apps/{slug2}"
|
||||
pub2 = http.post(
|
||||
f"{PLATFORM}/api/v1/apps/{slug2}/publish",
|
||||
headers=headers,
|
||||
json={"blueprint": draft2},
|
||||
)
|
||||
if pub2.status_code >= 400:
|
||||
print("database_per_app publish:", pub2.text)
|
||||
pub2.raise_for_status()
|
||||
pj2 = pub2.json()
|
||||
assert pj2.get("database_name"), pj2
|
||||
print("database_per_app", pj2["database_name"], "schema", pj2["schema_name"])
|
||||
|
||||
# viewer token 不应能 publish
|
||||
issued = http.post(
|
||||
f"{PLATFORM}/api/v1/auth/token",
|
||||
json={
|
||||
"tenant_id": auth["tenant_id"],
|
||||
"user_id": auth["user_id"],
|
||||
"role": "viewer",
|
||||
"secret": "dev-only-change-me",
|
||||
},
|
||||
)
|
||||
if issued.status_code == 200:
|
||||
vtok = issued.json()["access_token"]
|
||||
forbid = http.post(
|
||||
f"{PLATFORM}/api/v1/apps/{slug}/publish",
|
||||
headers={"Authorization": f"Bearer {vtok}"},
|
||||
json={"blueprint": draft},
|
||||
)
|
||||
assert forbid.status_code == 403, forbid.text
|
||||
print("rbac viewer deny publish ok")
|
||||
else:
|
||||
print("skip rbac token issue:", issued.status_code, issued.text[:120])
|
||||
|
||||
print("E2E OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
102
scripts/hard_restart_host.py
Normal file
102
scripts/hard_restart_host.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Hard-reset native stack ports and start host services."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LOG = ROOT / ".runtime" / "logs"
|
||||
LOG.mkdir(parents=True, exist_ok=True)
|
||||
PORTS = (8888, 8001, 8180, 5173)
|
||||
|
||||
|
||||
def pids_on_port(port: int) -> list[int]:
|
||||
out = subprocess.check_output(["netstat", "-ano"], text=True, errors="replace")
|
||||
pids: set[int] = set()
|
||||
needle = f":{port}"
|
||||
for line in out.splitlines():
|
||||
if needle not in line or "LISTENING" not in line.upper():
|
||||
continue
|
||||
parts = line.split()
|
||||
try:
|
||||
pids.add(int(parts[-1]))
|
||||
except ValueError:
|
||||
pass
|
||||
return sorted(pids)
|
||||
|
||||
|
||||
def kill_pid(pid: int) -> None:
|
||||
if pid <= 0:
|
||||
return
|
||||
subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], check=False, capture_output=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("==> hard stop ports", PORTS)
|
||||
for name in ("watchdog.pid", "ai-service.pid", "platform.pid", "gateway.pid", "web.pid"):
|
||||
pf = LOG / name
|
||||
if pf.is_file():
|
||||
try:
|
||||
kill_pid(int(pf.read_text(encoding="ascii").strip()))
|
||||
except ValueError:
|
||||
pass
|
||||
pf.unlink(missing_ok=True)
|
||||
|
||||
for port in PORTS:
|
||||
for pid in pids_on_port(port):
|
||||
print(f" kill :{port} pid={pid}")
|
||||
kill_pid(pid)
|
||||
time.sleep(2)
|
||||
|
||||
# leftover listeners
|
||||
for port in PORTS:
|
||||
for pid in pids_on_port(port):
|
||||
print(f" re-kill :{port} pid={pid}")
|
||||
kill_pid(pid)
|
||||
time.sleep(1)
|
||||
|
||||
print("==> start-host.ps1 -NoBrowser")
|
||||
# Prefer -UseHost path without docker
|
||||
ps1 = ROOT / "start-host.ps1"
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(ps1),
|
||||
"-NoBrowser",
|
||||
],
|
||||
cwd=str(ROOT),
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise SystemExit(f"start-host failed code={proc.returncode}")
|
||||
|
||||
# verify
|
||||
for url in (
|
||||
"http://127.0.0.1:5173/",
|
||||
"http://127.0.0.1:8001/health",
|
||||
"http://127.0.0.1:8180/gateway/health",
|
||||
):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=3) as r:
|
||||
print(f"OK {url} -> {r.status}")
|
||||
except Exception as e:
|
||||
print(f"FAIL {url} -> {e}")
|
||||
raise SystemExit(1)
|
||||
|
||||
# only one listener on 8001
|
||||
p8001 = pids_on_port(8001)
|
||||
print("listeners :8001 ->", p8001)
|
||||
p5173 = pids_on_port(5173)
|
||||
print("listeners :5173 ->", p5173)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
187
scripts/probe_dashscope.py
Normal file
187
scripts/probe_dashscope.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""Probe DashScope / 通义千问 connectivity and latency."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load_env() -> None:
|
||||
for envp in (ROOT / ".env", ROOT / "ai-service" / ".env"):
|
||||
if not envp.exists():
|
||||
continue
|
||||
for line in envp.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
k, v = k.strip(), v.strip().strip('"').strip("'")
|
||||
if k and k not in os.environ:
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
def post(base: str, key: str, payload: dict, timeout: float, label: str) -> tuple[float, int | None]:
|
||||
t0 = time.time()
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as c:
|
||||
r = c.post(
|
||||
f"{base}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
||||
json=payload,
|
||||
)
|
||||
dt = time.time() - t0
|
||||
print(f"[{label}] status={r.status_code} time={dt:.1f}s bytes_in={len(r.content)}")
|
||||
if r.status_code >= 400:
|
||||
print(f"[{label}] body {r.text[:500].replace(chr(10), ' ')}")
|
||||
else:
|
||||
j = r.json()
|
||||
txt = j["choices"][0]["message"]["content"]
|
||||
if isinstance(txt, list):
|
||||
txt = "".join((x.get("text") if isinstance(x, dict) else str(x)) for x in txt)
|
||||
print(f"[{label}] ok content_len={len(str(txt))} preview={str(txt)[:100]!r}")
|
||||
return dt, r.status_code
|
||||
except Exception as e:
|
||||
dt = time.time() - t0
|
||||
print(f"[{label}] FAIL after {dt:.1f}s: {type(e).__name__}: {e}")
|
||||
return dt, None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
load_env()
|
||||
key = (os.getenv("DASHSCOPE_API_KEY") or "").strip()
|
||||
base = (os.getenv("DASHSCOPE_BASE_URL") or "https://dashscope.aliyuncs.com/compatible-mode/v1").rstrip("/")
|
||||
model = (os.getenv("VISION_MODEL") or "qwen3.6-plus").strip()
|
||||
print(f"base={base}")
|
||||
print(f"model={model}")
|
||||
print(f"key_set={bool(key)} key_len={len(key)}")
|
||||
if not key:
|
||||
print("NO DASHSCOPE_API_KEY")
|
||||
return 1
|
||||
|
||||
host = "dashscope.aliyuncs.com"
|
||||
t0 = time.time()
|
||||
try:
|
||||
ips = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
|
||||
print(f"DNS ok {time.time() - t0:.2f}s -> {sorted({x[4][0] for x in ips})[:4]}")
|
||||
except Exception as e:
|
||||
print(f"DNS FAIL {type(e).__name__}: {e}")
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
ctx = ssl.create_default_context()
|
||||
with socket.create_connection((host, 443), timeout=10) as sock:
|
||||
with ctx.wrap_socket(sock, server_hostname=host) as ssock:
|
||||
print(f"TLS ok {time.time() - t0:.2f}s protocol={ssock.version()}")
|
||||
except Exception as e:
|
||||
print(f"TLS FAIL {type(e).__name__}: {e}")
|
||||
|
||||
# 1) text-only
|
||||
post(
|
||||
base,
|
||||
key,
|
||||
{
|
||||
"model": model,
|
||||
"temperature": 0,
|
||||
"messages": [{"role": "user", "content": "只回复:通义可达"}],
|
||||
"max_tokens": 32,
|
||||
},
|
||||
30.0,
|
||||
"text-only",
|
||||
)
|
||||
|
||||
# 2) tiny image
|
||||
png_b64 = (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
post(
|
||||
base,
|
||||
key,
|
||||
{
|
||||
"model": model,
|
||||
"temperature": 0,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "这张图是什么颜色?一句话。"},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{png_b64}"}},
|
||||
],
|
||||
}
|
||||
],
|
||||
"max_tokens": 64,
|
||||
},
|
||||
60.0,
|
||||
"tiny-image",
|
||||
)
|
||||
|
||||
# 3) real refs
|
||||
refs = list((ROOT / "test" / "refs").glob("*.png"))[:2]
|
||||
if not refs:
|
||||
refs = list((ROOT / "test").rglob("board_*.png"))[:2]
|
||||
print("refs", [f"{p.name}:{p.stat().st_size}" for p in refs])
|
||||
if refs:
|
||||
content: list[dict] = [{"type": "text", "text": "用一句话描述这些界面截图里最显眼的标题。"}]
|
||||
total = 0
|
||||
for p in refs:
|
||||
raw = p.read_bytes()
|
||||
total += len(raw)
|
||||
b64 = base64.b64encode(raw).decode("ascii")
|
||||
content.append({"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}})
|
||||
print(f"ref payload images_bytes={total} b64_approx={total * 4 // 3}")
|
||||
post(
|
||||
base,
|
||||
key,
|
||||
{
|
||||
"model": model,
|
||||
"temperature": 0,
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
"max_tokens": 128,
|
||||
},
|
||||
150.0,
|
||||
"real-refs",
|
||||
)
|
||||
|
||||
# long prompt + 2 images like fidelity scoring (heavier)
|
||||
long_text = ("请逐区对照并打分。" + "区" * 800)[:2000]
|
||||
content2 = [{"type": "text", "text": long_text}] + content[1:]
|
||||
post(
|
||||
base,
|
||||
key,
|
||||
{
|
||||
"model": model,
|
||||
"temperature": 0,
|
||||
"messages": [{"role": "user", "content": content2}],
|
||||
"max_tokens": 512,
|
||||
},
|
||||
150.0,
|
||||
"fidelity-like",
|
||||
)
|
||||
|
||||
# 4) alternate model text
|
||||
if model != "qwen-vl-plus":
|
||||
post(
|
||||
base,
|
||||
key,
|
||||
{
|
||||
"model": "qwen-vl-plus",
|
||||
"temperature": 0,
|
||||
"messages": [{"role": "user", "content": "只回复:vl-plus可达"}],
|
||||
"max_tokens": 32,
|
||||
},
|
||||
30.0,
|
||||
"alt-qwen-vl-plus-text",
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
120
scripts/restart_ai_only.py
Normal file
120
scripts/restart_ai_only.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Kill port 8001 holders, then start ai-service."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LOG = ROOT / ".runtime" / "logs"
|
||||
LOG.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def pids_on_port(port: int) -> list[int]:
|
||||
out = subprocess.check_output(["netstat", "-ano"], text=True, errors="replace")
|
||||
pids: set[int] = set()
|
||||
needle = f":{port}"
|
||||
for line in out.splitlines():
|
||||
if needle not in line:
|
||||
continue
|
||||
if "LISTENING" not in line.upper():
|
||||
continue
|
||||
parts = line.split()
|
||||
try:
|
||||
pids.add(int(parts[-1]))
|
||||
except ValueError:
|
||||
pass
|
||||
return sorted(pids)
|
||||
|
||||
|
||||
def kill_pid(pid: int) -> None:
|
||||
if pid <= 0:
|
||||
return
|
||||
subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], check=False, capture_output=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("stopping old ai...")
|
||||
# stop watchdog first if present
|
||||
wd = LOG / "watchdog.pid"
|
||||
if wd.is_file():
|
||||
try:
|
||||
kill_pid(int(wd.read_text(encoding="ascii").strip()))
|
||||
except ValueError:
|
||||
pass
|
||||
wd.unlink(missing_ok=True)
|
||||
|
||||
for name in ("ai-service.pid",):
|
||||
pf = LOG / name
|
||||
if pf.is_file():
|
||||
try:
|
||||
kill_pid(int(pf.read_text(encoding="ascii").strip()))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
for pid in pids_on_port(8001):
|
||||
print(f" free 8001 pid={pid}")
|
||||
kill_pid(pid)
|
||||
time.sleep(2)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["WEB_BASE"] = "http://127.0.0.1:5173"
|
||||
env["PLATFORM_BASE"] = "http://127.0.0.1:8888"
|
||||
env["GATEWAY_BASE"] = "http://127.0.0.1:8180"
|
||||
env["GENERATE_LOG_DIR"] = str(LOG / "generate")
|
||||
env["FIDELITY_SHOT_DIR"] = str(ROOT / ".runtime" / "fidelity_shots")
|
||||
|
||||
stamp = time.strftime("%H%M%S")
|
||||
out_path = LOG / f"ai-service.{stamp}.out.log"
|
||||
err_path = LOG / f"ai-service.{stamp}.err.log"
|
||||
out_f = out_path.open("w", encoding="utf-8")
|
||||
err_f = err_path.open("w", encoding="utf-8")
|
||||
# also point stable names via copies later; use unique to avoid locks
|
||||
(LOG / "ai-service.out.log.path").write_text(str(out_path), encoding="utf-8")
|
||||
|
||||
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
p = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"app:app",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"8001",
|
||||
"--reload",
|
||||
],
|
||||
cwd=str(ROOT / "ai-service"),
|
||||
env=env,
|
||||
stdout=out_f,
|
||||
stderr=err_f,
|
||||
creationflags=flags,
|
||||
)
|
||||
(LOG / "ai-service.pid").write_text(str(p.pid), encoding="ascii")
|
||||
print(f"started ai-service pid={p.pid}")
|
||||
print(f" logs: {out_path.name} / {err_path.name}")
|
||||
|
||||
for i in range(45):
|
||||
time.sleep(1)
|
||||
try:
|
||||
with urllib.request.urlopen("http://127.0.0.1:8001/docs", timeout=2) as r:
|
||||
if r.status == 200:
|
||||
print("OK AI http://127.0.0.1:8001")
|
||||
return
|
||||
except Exception as e:
|
||||
if i % 5 == 4:
|
||||
print(f" waiting... ({type(e).__name__})")
|
||||
# show err log snippet
|
||||
try:
|
||||
print(err_path.read_text(encoding="utf-8", errors="replace")[-400:])
|
||||
except Exception:
|
||||
pass
|
||||
raise SystemExit("AI failed to become ready on :8001")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user