chore: initial commit of ai site platform

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-07-31 10:19:22 +08:00
commit 6366859bb3
222 changed files with 47313 additions and 0 deletions

View 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
View 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
View 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
View 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()

View 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()

402
scripts/lib-aijz-deploy.sh Normal file
View File

@@ -0,0 +1,402 @@
# shellcheck shell=bash
# AI 建站 Linux 部署公共函数。由 start/restart/stop/pull-and-restart 在 ROOT 下 source。
run_sudo() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
else
sudo "$@"
fi
}
ensure_runtime_dirs() {
mkdir -p "$ROOT/.runtime/pgdata" "$ROOT/.runtime/uploads" "$ROOT/.runtime/logs" \
"$ROOT/.runtime/logs/generate" "$ROOT/web/dist"
}
ensure_env_file() {
if [ ! -f "$ROOT/.env" ]; then
if [ -f "$ROOT/.env.example" ]; then
cp "$ROOT/.env.example" "$ROOT/.env"
echo "已从 .env.example 创建 .env请按需填写 API Key。"
else
cat >"$ROOT/.env" <<'EOF'
LLM_PROVIDER=deepseek
DEEPSEEK_API_KEY=
MINIMAX_API_KEY=
EOF
echo "已创建默认 .env"
fi
fi
sed -i 's/\r$//' "$ROOT/.env" 2>/dev/null || true
# 供 compose 变量替换与本脚本健康检查使用
set -a
# shellcheck disable=SC1091
[ -f "$ROOT/.env" ] && . "$ROOT/.env"
set +a
export AIJZ_WEB_PUBLISH="${AIJZ_WEB_PUBLISH:-127.0.0.1:5173}"
export AIJZ_GATEWAY_PUBLISH="${AIJZ_GATEWAY_PUBLISH:-127.0.0.1:8180}"
export AIJZ_PLATFORM_PUBLISH="${AIJZ_PLATFORM_PUBLISH:-127.0.0.1:8888}"
export AIJZ_AI_PUBLISH="${AIJZ_AI_PUBLISH:-127.0.0.1:8001}"
export AIJZ_PG_PUBLISH="${AIJZ_PG_PUBLISH:-127.0.0.1:5432}"
export AIJZ_ENABLE_HOST_NGINX="${AIJZ_ENABLE_HOST_NGINX:-0}"
export AIJZ_DOMAIN="${AIJZ_DOMAIN:-}"
export AIJZ_PUBLIC_BASE_URL="${AIJZ_PUBLIC_BASE_URL:-}"
}
# 若 .env 设了 AIJZ_PUBLIC_BASE_URL同步到 platform.docker.yaml挂载文件restart platform 即生效)
apply_public_base_url() {
local base="${AIJZ_PUBLIC_BASE_URL:-}"
local pf="$ROOT/platform/etc/platform.docker.yaml"
[ -n "$base" ] || return 0
[ -f "$pf" ] || return 0
base="${base%/}"
local storage_base="${base}/api/v1/storage"
if grep -q '^PublicBaseURL:' "$pf"; then
sed -i "s|^PublicBaseURL:.*|PublicBaseURL: \"${base}\"|" "$pf"
fi
if grep -q '^ PublicBase:' "$pf"; then
sed -i "s|^ PublicBase:.*| PublicBase: \"${storage_base}\"|" "$pf"
fi
echo "已同步 PublicBaseURL -> ${base}platform/etc/platform.docker.yaml"
}
sync_ssl_certs() {
local domain="${AIJZ_DOMAIN:-}"
[ -n "$domain" ] || return 0
local ssl_dir="/etc/ssl/aijianzhan/$domain"
run_sudo mkdir -p "$ssl_dir"
if [ -f "$ROOT/nginx/$domain.pem" ] && [ -f "$ROOT/nginx/$domain.key" ]; then
run_sudo cp -f "$ROOT/nginx/$domain.pem" "$ssl_dir/fullchain.pem"
run_sudo cp -f "$ROOT/nginx/$domain.key" "$ssl_dir/privkey.pem"
elif [ -f "$ROOT/nginx/fullchain.pem" ] && [ -f "$ROOT/nginx/privkey.pem" ]; then
run_sudo cp -f "$ROOT/nginx/fullchain.pem" "$ROOT/nginx/privkey.pem" "$ssl_dir/"
elif [ -f "$ROOT/nginx/$domain/fullchain.pem" ] && [ -f "$ROOT/nginx/$domain/privkey.pem" ]; then
run_sudo cp -f "$ROOT/nginx/$domain/fullchain.pem" "$ROOT/nginx/$domain/privkey.pem" "$ssl_dir/"
else
echo "提示: 未找到 nginx/ 下证书,宿主机 HTTPS 需手动放入 $ssl_dir" >&2
return 0
fi
run_sudo chmod 644 "$ssl_dir/fullchain.pem" 2>/dev/null || true
run_sudo chmod 600 "$ssl_dir/privkey.pem" 2>/dev/null || true
echo "证书已同步 -> $ssl_dir"
}
host_nginx_online() {
command -v systemctl >/dev/null 2>&1 || return 1
systemctl is-active nginx >/dev/null 2>&1 && return 0
systemctl is-active nginx.service >/dev/null 2>&1 && return 0
return 1
}
ensure_host_nginx_started() {
command -v nginx >/dev/null 2>&1 || {
echo "错误: 未安装 nginx无法启用 AIJZ_ENABLE_HOST_NGINX" >&2
exit 1
}
if host_nginx_online; then
return 0
fi
echo "宿主机 Nginx 未在线,尝试启动..."
run_sudo systemctl start nginx 2>/dev/null || run_sudo systemctl start nginx.service
run_sudo systemctl enable nginx 2>/dev/null || true
host_nginx_online || {
echo "错误: 无法启动宿主机 nginx" >&2
exit 1
}
}
install_host_nginx_site_conf() {
local domain="${AIJZ_DOMAIN:-}"
[ -n "$domain" ] || return 0
[ "${AIJZ_ENABLE_HOST_NGINX:-0}" = "1" ] || return 0
local tpl="$ROOT/nginx/aijz.host.conf"
local out="/etc/nginx/conf.d/aijz_${domain}.conf"
local web_port
web_port="$(publish_host_port "${AIJZ_WEB_PUBLISH:-127.0.0.1:5173}")"
[ -f "$tpl" ] || {
echo "未找到 $tpl,跳过宿主机 Nginx 配置" >&2
return 0
}
mkdir -p "$ROOT/verify-root"
sync_ssl_certs
sed -e "s|__DOMAIN__|${domain}|g" \
-e "s|__WEB_PORT__|${web_port}|g" \
-e "s|__VERIFY_ROOT__|${ROOT}/verify-root|g" \
"$tpl" | run_sudo tee "$out" >/dev/null
if ! run_sudo nginx -t 2>/dev/null; then
echo "错误: nginx -t 失败,请检查 $out" >&2
exit 1
fi
if host_nginx_online; then
run_sudo systemctl reload nginx 2>/dev/null && echo "宿主机 Nginx 已重载($out"
else
ensure_host_nginx_started
fi
}
reload_web_nginx() {
local cid
cid="$(compose_cmd ps -q web 2>/dev/null | head -1)"
if [ -z "$cid" ]; then
echo "web 容器未运行,跳过 reload" >&2
return 1
fi
run_sudo docker exec "$cid" nginx -t >/dev/null 2>&1 || {
echo "错误: 容器内 nginx -t 失败" >&2
return 1
}
run_sudo docker exec "$cid" nginx -s reload
echo "web 容器 nginx 已 reloadweb/nginx.conf"
}
restart_service() {
local svc="$1"
compose_cmd restart "$svc"
echo "已 restart $svc"
}
reload_host_nginx() {
[ "${AIJZ_ENABLE_HOST_NGINX:-0}" = "1" ] || {
echo "AIJZ_ENABLE_HOST_NGINX 未启用,跳过宿主机 Nginx" >&2
return 0
}
install_host_nginx_site_conf
}
reload_all_config() {
ensure_env_file
apply_public_base_url
reload_host_nginx || true
reload_web_nginx || true
restart_service platform
restart_service gateway
compose_cmd up -d --force-recreate ai >/dev/null 2>&1 || compose_cmd restart ai
echo "配置重载完成(未 rebuild 镜像、未重建 web/dist"
}
ensure_docker() {
if command -v docker >/dev/null 2>&1 && run_sudo docker info >/dev/null 2>&1; then
echo "Docker 已就绪."
return 0
fi
if command -v docker >/dev/null 2>&1; then
echo "Docker 守护进程未连接,尝试启动..."
run_sudo systemctl start docker 2>/dev/null || run_sudo systemctl start podman 2>/dev/null || true
if run_sudo docker info >/dev/null 2>&1; then
echo "Docker 已就绪."
return 0
fi
fi
echo "错误: Docker 不可用。请先安装并启动 docker 后再执行。" >&2
exit 1
}
resolve_compose_cmd() {
if run_sudo docker compose version >/dev/null 2>&1; then
echo "docker compose"
return
fi
if command -v docker-compose >/dev/null 2>&1; then
echo "docker-compose"
return
fi
if [ -x /usr/local/bin/docker-compose ]; then
echo "/usr/local/bin/docker-compose"
return
fi
echo ""
}
COMPOSE_CMD=""
compose_cmd() {
if [ -z "$COMPOSE_CMD" ]; then
COMPOSE_CMD="$(resolve_compose_cmd)"
fi
if [ -z "$COMPOSE_CMD" ]; then
echo "错误: 找不到 docker compose / docker-compose" >&2
exit 1
fi
# shellcheck disable=SC2086
run_sudo env \
DOCKER_BASE_REGISTRY="${DOCKER_BASE_REGISTRY}" \
GOPROXY="${GOPROXY}" \
AIJZ_WEB_PUBLISH="${AIJZ_WEB_PUBLISH}" \
AIJZ_GATEWAY_PUBLISH="${AIJZ_GATEWAY_PUBLISH}" \
AIJZ_PLATFORM_PUBLISH="${AIJZ_PLATFORM_PUBLISH}" \
AIJZ_AI_PUBLISH="${AIJZ_AI_PUBLISH}" \
AIJZ_PG_PUBLISH="${AIJZ_PG_PUBLISH}" \
$COMPOSE_CMD "$@"
}
export_defaults() {
export DOCKER_BASE_REGISTRY="${DOCKER_BASE_REGISTRY:-docker.m.daocloud.io/library}"
export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
export REGISTRY_MIRROR="${REGISTRY_MIRROR:-docker.m.daocloud.io/library/}"
}
# 从 "127.0.0.1:5173" 或 "5173" 取出宿主机端口号
publish_host_port() {
local pub="$1"
echo "$pub" | awk -F: '{print $NF}'
}
port_in_use() {
local port="$1"
if command -v ss >/dev/null 2>&1; then
run_sudo ss -tlnH "sport = :$port" 2>/dev/null | grep -q . && return 0
ss -tlnH "sport = :$port" 2>/dev/null | grep -q . && return 0
return 1
fi
if command -v lsof >/dev/null 2>&1; then
lsof -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 && return 0
return 1
fi
return 1
}
# 检查 AI 建站将占用的宿主机端口;若已被本项目容器占用则放行
check_aijz_ports() {
local pairs=(
"web:$AIJZ_WEB_PUBLISH"
"gateway:$AIJZ_GATEWAY_PUBLISH"
"platform:$AIJZ_PLATFORM_PUBLISH"
"ai:$AIJZ_AI_PUBLISH"
"postgres:$AIJZ_PG_PUBLISH"
)
echo "端口规划核对(相对 yh_webAI建站 5173/8180/8888/8001/5432宇恒 8088/9080/9081 + 宿主机 80/443。"
local name pub port conflict=0
local self_running=0
if compose_cmd ps -q 2>/dev/null | grep -q .; then
self_running=1
fi
for item in "${pairs[@]}"; do
name="${item%%:*}"
pub="${item#*:}"
port="$(publish_host_port "$pub")"
[ -z "$port" ] && continue
if port_in_use "$port"; then
if [ "$self_running" -eq 1 ]; then
echo " 提示: 端口 $port$name)已占用,疑为本栈,继续。"
else
echo " 错误: 宿主机端口 $port 已被占用(服务 $name$pub)。" >&2
echo " 请改 .env 中对应 AIJZ_*_PUBLISH或停掉占用进程。" >&2
conflict=1
fi
else
echo " OK $name$pub"
fi
done
[ "$conflict" -eq 0 ] || exit 1
}
# 构建 web/dist优先本机 npm否则用 Docker node 镜像
build_web_dist() {
echo "构建 web dist ..."
if command -v npm >/dev/null 2>&1; then
(
cd "$ROOT/web"
if [ -f package-lock.json ]; then
npm ci --legacy-peer-deps 2>/dev/null || npm install --legacy-peer-deps
else
npm install --legacy-peer-deps
fi
npm run build
)
else
echo "本机无 npm使用 Docker node 构建..."
run_sudo docker run --rm \
-v "$ROOT/web:/app" \
-v "$ROOT/web/dist:/app/dist" \
-w /app \
"${REGISTRY_MIRROR}node:20-alpine" \
sh -c "(npm ci --legacy-peer-deps 2>/dev/null || npm install --legacy-peer-deps) && npm run build"
fi
if [ ! -f "$ROOT/web/dist/index.html" ]; then
echo "错误: web/dist/index.html 不存在,构建失败。" >&2
exit 1
fi
echo "web/dist 就绪."
}
stack_up() {
local rebuild="${1:-0}"
export_defaults
ensure_runtime_dirs
ensure_env_file
apply_public_base_url
check_aijz_ports
build_web_dist
if [ "$rebuild" = "1" ]; then
compose_cmd up -d --build --force-recreate
else
if ! compose_cmd up -d; then
echo "compose up 失败,尝试 --build ..."
compose_cmd up -d --build
fi
fi
install_host_nginx_site_conf || true
}
stack_down() {
compose_cmd down --remove-orphans 2>/dev/null || true
}
healthcheck() {
local ok=0 i
local gw_port web_port
gw_port="$(publish_host_port "${AIJZ_GATEWAY_PUBLISH:-127.0.0.1:8180}")"
web_port="$(publish_host_port "${AIJZ_WEB_PUBLISH:-127.0.0.1:5173}")"
echo "等待 gateway(:$gw_port) / web(:$web_port) ..."
for i in $(seq 1 60); do
if curl -fsS --max-time 2 "http://127.0.0.1:${gw_port}/gateway/health" >/dev/null 2>&1 \
&& curl -fsS --max-time 2 "http://127.0.0.1:${web_port}/" >/dev/null 2>&1; then
ok=1
break
fi
sleep 2
done
if [ "$ok" -eq 1 ]; then
echo "健康检查通过."
else
echo "警告: 健康检查超时,请查看: docker compose -f $ROOT/docker-compose.yml logs" >&2
fi
compose_cmd exec -T postgres psql -U platform -d platform -c 'ALTER USER platform CREATEDB;' >/dev/null 2>&1 || true
}
print_endpoints() {
local pub_line=""
if [ -n "${AIJZ_DOMAIN:-}" ] && [ "${AIJZ_ENABLE_HOST_NGINX:-0}" = "1" ]; then
pub_line=" Public https://${AIJZ_DOMAIN}"
fi
cat <<EOF
========================================
Web http://127.0.0.1:$(publish_host_port "${AIJZ_WEB_PUBLISH:-127.0.0.1:5173}")
Gateway http://127.0.0.1:$(publish_host_port "${AIJZ_GATEWAY_PUBLISH:-127.0.0.1:8180}")
${pub_line}
Account demo / demo123
Data $ROOT/.runtime
Stop ./stop.sh
重载配置 ./reload-config.sh不整栈 rebuild
绑定 默认 127.0.0.1(与 yh_web 同机不抢 80/443/8088
========================================
EOF
}
git_pull_hard() {
local branch="${GIT_BRANCH:-master}"
echo "拉取代码branch=$branch..."
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "错误: 当前不是 git 仓库,跳过拉取。" >&2
return 1
fi
git fetch origin --progress
if [ "${FORCE_GIT_RESET:-1}" = "1" ]; then
git reset --hard "origin/$branch"
else
git checkout "$branch" 2>/dev/null || true
git merge --ff-only "origin/$branch"
fi
echo "当前: $(git rev-parse --short HEAD) $(git log -1 --pretty=%s)"
}

96
scripts/linux/common.sh Normal file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# 宇信达智建 — Linux 公共函数(宿主机物理目录模式,不依赖 Docker
# shellcheck disable=SC2034
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RUNTIME="$ROOT/.runtime"
LOG_DIR="$RUNTIME/logs"
PID_DIR="$RUNTIME/pids"
VENV_DIR="$RUNTIME/venv"
PGDATA="$RUNTIME/postgres"
UPLOAD_DIR="$ROOT/platform/data/uploads"
PG_PORT="${AJZ_PG_PORT:-5432}"
PG_USER="${AJZ_PG_USER:-platform}"
PG_PASS="${AJZ_PG_PASS:-platform}"
PG_DB="${AJZ_PG_DB:-platform}"
mkdir -p "$LOG_DIR" "$PID_DIR" "$UPLOAD_DIR"
c_info() { printf '\033[36m==> %s\033[0m\n' "$*"; }
c_ok() { printf '\033[32m OK %s\033[0m\n' "$*"; }
c_warn() { printf '\033[33m !! %s\033[0m\n' "$*"; }
c_err() { printf '\033[31m ERR %s\033[0m\n' "$*" >&2; }
have_cmd() { command -v "$1" >/dev/null 2>&1; }
docker_ready() {
have_cmd docker || return 1
docker info >/dev/null 2>&1
}
port_listen() {
local port="$1"
if have_cmd ss; then
ss -ltn "( sport = :$port )" 2>/dev/null | grep -q ":$port"
elif have_cmd netstat; then
netstat -ltn 2>/dev/null | grep -q "[.:]$port "
else
(echo >/dev/tcp/127.0.0.1/"$port") >/dev/null 2>&1
fi
}
wait_port() {
local port="$1" name="$2" sec="${3:-40}"
local i=0
while (( i < sec * 2 )); do
if port_listen "$port"; then
c_ok "$name 端口 $port 已监听"
return 0
fi
sleep 0.5
((i++)) || true
done
c_warn "$name 端口 $port 未在 ${sec}s 内就绪"
return 1
}
wait_http() {
local url="$1" name="$2" sec="${3:-40}"
local i=0
while (( i < sec * 2 )); do
if curl -fsS --max-time 2 "$url" >/dev/null 2>&1; then
c_ok "$name 就绪 $url"
return 0
fi
sleep 0.5
((i++)) || true
done
c_warn "$name 未在 ${sec}s 内就绪: $url"
return 1
}
detect_os() {
if [[ -f /etc/os-release ]]; then
# shellcheck source=/dev/null
. /etc/os-release
echo "${ID:-unknown}"
else
echo "unknown"
fi
}
sudo_run() {
if [[ "$(id -u)" -eq 0 ]]; then
"$@"
elif have_cmd sudo; then
sudo "$@"
else
c_err "需要 root 或 sudo 才能安装系统包: $*"
return 1
fi
}
export ROOT RUNTIME LOG_DIR PID_DIR VENV_DIR PGDATA UPLOAD_DIR
export PG_PORT PG_USER PG_PASS PG_DB

224
scripts/linux/setup-host.sh Normal file
View File

@@ -0,0 +1,224 @@
#!/usr/bin/env bash
# 在宿主机物理目录自动配置环境(无 Docker
# 数据落盘:$ROOT/.runtime/{postgres,venv,logs,pids} 与 platform/data/uploads
# shellcheck disable=SC1091
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "$SCRIPT_DIR/common.sh"
c_info "根目录(物理): $ROOT"
c_info "运行时目录: $RUNTIME"
c_info "上传目录: $UPLOAD_DIR"
need_pkgs=()
have_cmd curl || need_pkgs+=(curl)
have_cmd git || need_pkgs+=(git)
have_cmd go || need_pkgs+=(golang-go)
have_cmd python3 || need_pkgs+=(python3)
have_cmd pip3 || need_pkgs+=(python3-pip)
python3 -c "import venv" 2>/dev/null || need_pkgs+=(python3-venv)
have_cmd node || need_pkgs+=(nodejs)
have_cmd npm || need_pkgs+=(npm)
have_cmd psql || need_pkgs+=(postgresql-client)
have_cmd initdb || have_cmd pg_ctl || need_pkgs+=(postgresql)
install_pkgs() {
local os
os="$(detect_os)"
c_info "安装系统包 (OS=$os): ${need_pkgs[*]}"
case "$os" in
ubuntu|debian|linuxmint|pop)
sudo_run apt-get update -y
local pkgs=()
for p in "${need_pkgs[@]}"; do
case "$p" in
golang-go) pkgs+=(golang-go) ;;
python3) pkgs+=(python3 python3-venv python3-dev) ;;
python3-pip) pkgs+=(python3-pip) ;;
python3-venv) pkgs+=(python3-venv) ;;
nodejs) pkgs+=(nodejs) ;;
npm) pkgs+=(npm) ;;
postgresql) pkgs+=(postgresql postgresql-contrib) ;;
postgresql-client) pkgs+=(postgresql-client) ;;
*) pkgs+=("$p") ;;
esac
done
sudo_run apt-get install -y "${pkgs[@]}"
;;
fedora|rhel|centos|rocky|almalinux)
local pkgs=()
for p in "${need_pkgs[@]}"; do
case "$p" in
golang-go) pkgs+=(golang) ;;
python3-pip) pkgs+=(python3-pip) ;;
python3-venv) pkgs+=(python3) ;;
postgresql) pkgs+=(postgresql-server postgresql) ;;
postgresql-client) pkgs+=(postgresql) ;;
*) pkgs+=("$p") ;;
esac
done
if have_cmd dnf; then sudo_run dnf install -y "${pkgs[@]}"
else sudo_run yum install -y "${pkgs[@]}"; fi
;;
arch|manjaro)
local pkgs=()
for p in "${need_pkgs[@]}"; do
case "$p" in
golang-go) pkgs+=(go) ;;
python3) pkgs+=(python) ;;
python3-pip) pkgs+=(python-pip) ;;
python3-venv) ;;
postgresql-client) pkgs+=(postgresql) ;;
*) pkgs+=("$p") ;;
esac
done
sudo_run pacman -Sy --noconfirm "${pkgs[@]}"
;;
*)
c_err "未识别发行版 $os,请手动安装: go python3 node npm postgresql curl"
return 1
;;
esac
}
if ((${#need_pkgs[@]} > 0)); then
install_pkgs
else
c_ok "系统依赖已满足"
fi
missing=()
for c in go python3 curl; do have_cmd "$c" || missing+=("$c"); done
have_cmd node || have_cmd nodejs || missing+=("node")
have_cmd npm || missing+=("npm")
if ((${#missing[@]} > 0)); then
c_err "仍缺少命令: ${missing[*]}"
exit 1
fi
c_ok "$(go version)"
c_info "配置 Python venv → $VENV_DIR"
if [[ ! -d "$VENV_DIR" ]]; then
python3 -m venv "$VENV_DIR"
fi
# shellcheck source=/dev/null
source "$VENV_DIR/bin/activate"
pip install -U pip wheel >/dev/null
pip install -r "$ROOT/ai-service/requirements.txt"
pip install -r "$ROOT/agent_sdk/requirements.txt"
c_ok "venv 依赖已安装"
c_info "安装前端依赖 → $ROOT/web/node_modules"
if [[ ! -d "$ROOT/web/node_modules" ]]; then
(cd "$ROOT/web" && npm install)
else
c_ok "web/node_modules 已存在"
fi
find_pg_bin() {
local name="$1"
if have_cmd "$name"; then command -v "$name"; return; fi
local d
for d in /usr/lib/postgresql/*/bin /usr/pgsql-*/bin; do
if [[ -x "$d/$name" ]]; then echo "$d/$name"; return; fi
done
return 1
}
INITDB="$(find_pg_bin initdb || true)"
PG_CTL="$(find_pg_bin pg_ctl || true)"
PSQL="$(find_pg_bin psql || true)"
ensure_local_postgres() {
if [[ -z "$INITDB" || -z "$PG_CTL" || -z "$PSQL" ]]; then
c_warn "未找到 initdb/pg_ctl/psql跳过本地库初始化请自备 Postgres"
return 0
fi
# 已有可连库则复用
if PGPASSWORD="$PG_PASS" "$PSQL" -h 127.0.0.1 -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -c "SELECT 1" >/dev/null 2>&1; then
c_ok "复用已有 Postgres $PG_USER@$PG_DB:$PG_PORT"
PGPASSWORD="$PG_PASS" "$PSQL" -h 127.0.0.1 -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \
-c "ALTER USER $PG_USER CREATEDB;" >/dev/null 2>&1 || true
return 0
fi
mkdir -p "$PGDATA" "$RUNTIME/pg_sockets"
if [[ ! -f "$PGDATA/PG_VERSION" ]]; then
c_info "initdb → $PGDATA (物理目录)"
"$INITDB" -D "$PGDATA" --auth=trust -U postgres
{
echo ""
echo "listen_addresses = '127.0.0.1'"
echo "port = $PG_PORT"
echo "unix_socket_directories = '$RUNTIME/pg_sockets'"
} >> "$PGDATA/postgresql.conf"
fi
if ! "$PG_CTL" -D "$PGDATA" status >/dev/null 2>&1; then
c_info "启动本地 Postgres"
"$PG_CTL" -D "$PGDATA" -l "$LOG_DIR/postgres.log" -o "-k $RUNTIME/pg_sockets" start
sleep 1
fi
local sock="$RUNTIME/pg_sockets"
"$PSQL" -h "$sock" -U postgres -d postgres -v ON_ERROR_STOP=1 <<SQL
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '$PG_USER') THEN
CREATE ROLE $PG_USER LOGIN PASSWORD '$PG_PASS' CREATEDB;
ELSE
ALTER ROLE $PG_USER WITH LOGIN PASSWORD '$PG_PASS' CREATEDB;
END IF;
END
\$\$;
SELECT 'ok' FROM pg_database WHERE datname = '$PG_DB';
SQL
if ! "$PSQL" -h "$sock" -U postgres -d postgres -tc "SELECT 1 FROM pg_database WHERE datname='$PG_DB'" | grep -q 1; then
"$PSQL" -h "$sock" -U postgres -d postgres -c "CREATE DATABASE $PG_DB OWNER $PG_USER;"
fi
# 校验 TCP
if ! PGPASSWORD="$PG_PASS" "$PSQL" -h 127.0.0.1 -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -c "SELECT 1" >/dev/null 2>&1; then
# 补充密码认证
cat > "$PGDATA/pg_hba.conf" <<EOF
local all all trust
host all all 127.0.0.1/32 trust
host all all ::1/128 trust
EOF
"$PG_CTL" -D "$PGDATA" reload || "$PG_CTL" -D "$PGDATA" restart -m fast
sleep 1
fi
PGPASSWORD="$PG_PASS" "$PSQL" -h 127.0.0.1 -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -c "SELECT 1" >/dev/null
c_ok "Postgres 就绪 (物理 data=$PGDATA)"
}
ensure_local_postgres
c_info "编译 platform / gateway"
(cd "$ROOT/platform" && go build -o platform .)
(cd "$ROOT/gateway" && go build -o gateway .)
c_ok "二进制已生成"
if [[ ! -f "$ROOT/ai-service/sample_inventory.xlsx" ]]; then
(cd "$ROOT/ai-service" && "$VENV_DIR/bin/python" make_sample_excel.py) || true
fi
date -Iseconds > "$RUNTIME/setup.ok"
cat > "$RUNTIME/env.sh" <<EOF
# 由 setup-host.sh 生成 — 物理目录运行时
export AJZ_ROOT="$ROOT"
export AJZ_RUNTIME="$RUNTIME"
export PATH="$VENV_DIR/bin:\$PATH"
export PGHOST=127.0.0.1
export PGPORT=$PG_PORT
export PGUSER=$PG_USER
export PGPASSWORD=$PG_PASS
export PGDATABASE=$PG_DB
EOF
c_info "环境配置完成(无 Docker全部落在物理目录"
c_ok "下一步: ./start.sh"

100
scripts/linux/start-host.sh Normal file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# 宿主机物理目录模式Docker 不可用或 ./start.sh --host
# shellcheck disable=SC1091
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "$SCRIPT_DIR/common.sh"
REBUILD=0
NO_BROWSER=0
for arg in "$@"; do
case "$arg" in
--rebuild|-Rebuild) REBUILD=1 ;;
--no-browser) NO_BROWSER=1 ;;
esac
done
start_bg() {
local name="$1" logfile="$2"
shift 2
local pidfile="$PID_DIR/$name.pid"
if [[ -f "$pidfile" ]] && kill -0 "$(cat "$pidfile")" 2>/dev/null; then
c_warn "$name 已在运行 pid=$(cat "$pidfile")"
return 0
fi
c_info "启动 $name"
nohup "$@" >>"$logfile" 2>&1 &
echo $! >"$pidfile"
c_ok "$name pid=$(cat "$pidfile") 日志 $logfile"
}
ensure_postgres_running() {
local pg_ctl=""
pg_ctl="$(command -v pg_ctl 2>/dev/null || true)"
if [[ -z "$pg_ctl" ]]; then
for d in /usr/lib/postgresql/*/bin /usr/pgsql-*/bin; do
[[ -x "$d/pg_ctl" ]] && pg_ctl="$d/pg_ctl" && break
done
fi
if [[ -n "$pg_ctl" && -f "$PGDATA/PG_VERSION" ]]; then
if ! "$pg_ctl" -D "$PGDATA" status >/dev/null 2>&1; then
c_info "启动物理目录 Postgres → $PGDATA"
mkdir -p "$RUNTIME/pg_sockets"
"$pg_ctl" -D "$PGDATA" -l "$LOG_DIR/postgres.log" -o "-k $RUNTIME/pg_sockets" start
sleep 1
fi
fi
}
c_warn "宿主机模式:数据落在 $RUNTIME"
if [[ ! -f "$RUNTIME/setup.ok" || "$REBUILD" -eq 1 ]]; then
bash "$SCRIPT_DIR/setup-host.sh"
fi
# shellcheck source=/dev/null
[[ -f "$RUNTIME/env.sh" ]] && source "$RUNTIME/env.sh"
ensure_postgres_running
if [[ "$REBUILD" -eq 1 || ! -x "$ROOT/platform/platform" ]]; then
(cd "$ROOT/platform" && go build -o platform .)
fi
if [[ "$REBUILD" -eq 1 || ! -x "$ROOT/gateway/gateway" ]]; then
(cd "$ROOT/gateway" && go build -o gateway .)
fi
PY="$VENV_DIR/bin/python"
[[ -x "$PY" ]] || PY="$(command -v python3)"
start_bg platform "$LOG_DIR/platform.log" \
bash -c "cd '$ROOT/platform' && exec ./platform -f etc/platform.yaml"
start_bg ai "$LOG_DIR/ai.log" \
bash -c "cd '$ROOT/ai-service' && exec '$PY' -m uvicorn app:app --host 127.0.0.1 --port 8001"
start_bg gateway "$LOG_DIR/gateway.log" \
bash -c "cd '$ROOT/gateway' && exec ./gateway -f etc/gateway.yaml"
start_bg web "$LOG_DIR/web.log" \
bash -c "cd '$ROOT/web' && exec npm run dev -- --host 127.0.0.1 --port 5173"
wait_port 8888 platform 45 || true
wait_http "http://127.0.0.1:8001/health" ai-service 45 || true
wait_http "http://127.0.0.1:8180/gateway/health" gateway 45 || true
wait_port 5173 web 60 || true
cat <<EOF
========================================
前端 http://127.0.0.1:5173
网关 http://127.0.0.1:8180
账号 demo / demo123
模式 宿主机物理目录
停止 ./stop.sh
========================================
EOF
if [[ "$NO_BROWSER" -eq 0 ]] && have_cmd xdg-open; then
xdg-open "http://127.0.0.1:5173" >/dev/null 2>&1 || true
fi

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# 停止宿主机模式进程
# shellcheck disable=SC1091
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck source=common.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh"
c_info "停止宿主机模式服务..."
stop_pidfile() {
local name="$1"
local f="$PID_DIR/$name.pid"
if [[ -f "$f" ]]; then
local pid
pid="$(cat "$f")"
if kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
sleep 0.3
kill -9 "$pid" 2>/dev/null || true
fi
rm -f "$f"
fi
}
for n in web gateway ai platform; do
stop_pidfile "$n"
done
for port in 5173 8180 8001 8888; do
if have_cmd fuser; then
fuser -k "${port}/tcp" >/dev/null 2>&1 || true
elif have_cmd lsof; then
pids="$(lsof -t -iTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)"
[[ -n "$pids" ]] && kill $pids 2>/dev/null || true
fi
done
if [[ "${1:-}" == "--with-db" ]]; then
pg_ctl_bin="$(command -v pg_ctl 2>/dev/null || true)"
if [[ -z "$pg_ctl_bin" ]]; then
for d in /usr/lib/postgresql/*/bin /usr/pgsql-*/bin; do
[[ -x "$d/pg_ctl" ]] && pg_ctl_bin="$d/pg_ctl" && break
done
fi
if [[ -n "$pg_ctl_bin" && -f "$PGDATA/PG_VERSION" ]]; then
"$pg_ctl_bin" -D "$PGDATA" stop -m fast || true
fi
fi
c_ok "已停止。数据仍在: $RUNTIME"

187
scripts/probe_dashscope.py Normal file
View 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
View 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()