fix gateway host routing and resilient test API fallback

This commit is contained in:
xmanrui
2026-03-02 04:07:04 +08:00
parent 82d67156cd
commit 0eb0cd9b70
8 changed files with 125 additions and 42 deletions
+5 -1
View File
@@ -542,7 +542,11 @@ export async function GET() {
agents: agentsWithStatus,
providers,
defaults: { model: defaultModel, fallbacks },
gateway: { port: config.gateway?.port || 18789, token: config.gateway?.auth?.token || "" },
gateway: {
port: config.gateway?.port || 18789,
token: config.gateway?.auth?.token || "",
host: config.gateway?.host || config.gateway?.hostname || "",
},
groupChats,
};
configCache = { data, ts: Date.now() };
+4
View File
@@ -126,3 +126,7 @@ export async function POST() {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function GET() {
return POST();
}
+4
View File
@@ -130,3 +130,7 @@ export async function POST() {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function GET() {
return POST();
}
+88 -25
View File
@@ -41,7 +41,7 @@ interface ConfigData {
agents: Agent[];
defaults: { model: string; fallbacks: string[] };
providers?: { id: string; accessMode?: "auth" | "api_key" }[];
gateway?: { port: number; token?: string };
gateway?: { port: number; token?: string; host?: string };
groupChats?: GroupChat[];
}
@@ -183,7 +183,7 @@ interface PlatformTestResult {
}
// 平台标签颜色
function PlatformBadge({ platform, agentId, gatewayPort, gatewayToken, t, testResult }: { platform: Platform; agentId: string; gatewayPort: number; gatewayToken?: string; t: TFunc; testResult?: PlatformTestResult | null }) {
function PlatformBadge({ platform, agentId, gatewayPort, gatewayToken, gatewayHost, t, testResult }: { platform: Platform; agentId: string; gatewayPort: number; gatewayToken?: string; gatewayHost?: string; t: TFunc; testResult?: PlatformTestResult | null }) {
const pName = platform.name;
let sessionKey: string;
@@ -198,8 +198,8 @@ function PlatformBadge({ platform, agentId, gatewayPort, gatewayToken, t, testRe
} else {
sessionKey = `agent:${agentId}:main`;
}
let sessionUrl = buildGatewayUrl(gatewayPort, "/chat", { session: sessionKey });
if (gatewayToken) sessionUrl = buildGatewayUrl(gatewayPort, "/chat", { session: sessionKey, token: gatewayToken });
let sessionUrl = buildGatewayUrl(gatewayPort, "/chat", { session: sessionKey }, gatewayHost);
if (gatewayToken) sessionUrl = buildGatewayUrl(gatewayPort, "/chat", { session: sessionKey, token: gatewayToken }, gatewayHost);
const badgeStyle = pName === "feishu"
? "bg-blue-500/20 text-blue-300 border border-blue-500/30 hover:bg-blue-500/40 hover:border-blue-400"
@@ -331,10 +331,10 @@ function AgentStatusBadge({ state, t }: { state?: string; t: TFunc }) {
}
// Agent 卡片
function AgentCard({ agent, gatewayPort, gatewayToken, t, testResult, platformTestResults, sessionTestResult, agentState, dmSessionResults, providerAccessModeMap }: { agent: Agent; gatewayPort: number; gatewayToken?: string; t: TFunc; testResult?: { ok: boolean; text?: string; error?: string; elapsed: number } | null; platformTestResults?: Record<string, PlatformTestResult | null>; sessionTestResult?: { ok: boolean; reply?: string; error?: string; elapsed: number } | null; agentState?: string; dmSessionResults?: Record<string, PlatformTestResult | null>; providerAccessModeMap?: Record<string, "auth" | "api_key"> }) {
function AgentCard({ agent, gatewayPort, gatewayToken, gatewayHost, t, testResult, platformTestResults, sessionTestResult, agentState, dmSessionResults, providerAccessModeMap }: { agent: Agent; gatewayPort: number; gatewayToken?: string; gatewayHost?: string; t: TFunc; testResult?: { ok: boolean; text?: string; error?: string; elapsed: number } | null; platformTestResults?: Record<string, PlatformTestResult | null>; sessionTestResult?: { ok: boolean; reply?: string; error?: string; elapsed: number } | null; agentState?: string; dmSessionResults?: Record<string, PlatformTestResult | null>; providerAccessModeMap?: Record<string, "auth" | "api_key"> }) {
const sessionKey = `agent:${agent.id}:main`;
let sessionUrl = buildGatewayUrl(gatewayPort, "/chat", { session: sessionKey });
if (gatewayToken) sessionUrl = buildGatewayUrl(gatewayPort, "/chat", { session: sessionKey, token: gatewayToken });
let sessionUrl = buildGatewayUrl(gatewayPort, "/chat", { session: sessionKey }, gatewayHost);
if (gatewayToken) sessionUrl = buildGatewayUrl(gatewayPort, "/chat", { session: sessionKey, token: gatewayToken }, gatewayHost);
const modelProvider = agent.model.includes("/") ? agent.model.split("/", 1)[0] : "default";
const modelAccessMode = providerAccessModeMap?.[modelProvider];
@@ -406,7 +406,7 @@ function AgentCard({ agent, gatewayPort, gatewayToken, t, testResult, platformTe
const dmResult = dmSessionResults ? dmSessionResults[dmKey] : undefined;
return (
<div key={i} className="grid grid-cols-2 items-center gap-2">
<PlatformBadge platform={p} agentId={agent.id} gatewayPort={gatewayPort} gatewayToken={gatewayToken} t={t} testResult={pResult} />
<PlatformBadge platform={p} agentId={agent.id} gatewayPort={gatewayPort} gatewayToken={gatewayToken} gatewayHost={gatewayHost} t={t} testResult={pResult} />
<div className="flex justify-end">
{dmResult === undefined ? (
<span className="text-sm text-[var(--text-muted)]">DM Session: --</span>
@@ -524,6 +524,33 @@ export default function Home() {
{ label: t("refresh.10m"), value: 600 },
];
const parseApiPayload = useCallback(async (resp: Response) => {
const raw = await resp.text();
let parsed: any = null;
try {
parsed = JSON.parse(raw);
} catch {}
const errorText = parsed?.error || raw || `HTTP ${resp.status}`;
return { ok: resp.ok, status: resp.status, data: parsed, errorText };
}, []);
const callTestApi = useCallback(async (url: string) => {
const requestWithMethod = async (method: "POST" | "GET") => {
const resp = await fetch(url, { method, cache: "no-store" });
return parseApiPayload(resp);
};
const first = await requestWithMethod("POST");
if (first.ok) return first.data;
const methodIssue = first.status === 405 || /method not allowed/i.test(first.errorText || "");
if (!methodIssue) throw new Error(first.errorText);
const fallback = await requestWithMethod("GET");
if (fallback.ok) return fallback.data;
throw new Error(fallback.errorText || first.errorText);
}, [parseApiPayload]);
const fetchData = useCallback((silent = false) => {
if (!silent) setLoading(true);
Promise.all([
@@ -629,8 +656,7 @@ export default function Home() {
const pending: Record<string, any> = {};
if (data) for (const a of data.agents) pending[a.id] = null;
setTestResults(pending);
fetch("/api/test-agents", { method: "POST" })
.then((r) => r.json())
callTestApi("/api/test-agents")
.then((resp) => {
if (resp.results) {
const map: Record<string, { ok: boolean; text?: string; error?: string; elapsed: number }> = {};
@@ -638,9 +664,14 @@ export default function Home() {
setTestResults(map);
}
})
.catch(() => {})
.catch((e) => {
const msg = e instanceof Error ? e.message : "Request failed";
const failed: Record<string, { ok: boolean; error: string; elapsed: number }> = {};
if (data) for (const a of data.agents) failed[a.id] = { ok: false, error: msg, elapsed: 0 };
setTestResults(failed);
})
.finally(() => setTesting(false));
}, [data]);
}, [data, callTestApi]);
const testAllPlatforms = useCallback(() => {
setTestingPlatforms(true);
@@ -654,8 +685,7 @@ export default function Home() {
}
}
setPlatformTestResults(pending);
fetch("/api/test-platforms", { method: "POST" })
.then((r) => r.json())
callTestApi("/api/test-platforms")
.then((resp) => {
if (resp.results) {
const map: Record<string, PlatformTestResult> = {};
@@ -663,17 +693,31 @@ export default function Home() {
setPlatformTestResults(map);
}
})
.catch(() => {})
.catch((e) => {
const msg = e instanceof Error ? e.message : "Request failed";
const failed: Record<string, PlatformTestResult> = {};
if (data) {
for (const a of data.agents) {
for (const p of a.platforms) {
failed[`${a.id}:${p.name}`] = {
ok: false,
error: msg,
elapsed: 0,
};
}
}
}
setPlatformTestResults(failed);
})
.finally(() => setTestingPlatforms(false));
}, [data]);
}, [data, callTestApi]);
const testAllSessions = useCallback(() => {
setTestingSessions(true);
const pending: Record<string, any> = {};
if (data) for (const a of data.agents) pending[a.id] = null;
setSessionTestResults(pending);
fetch("/api/test-sessions", { method: "POST" })
.then((r) => r.json())
callTestApi("/api/test-sessions")
.then((resp) => {
if (resp.results) {
const map: Record<string, { ok: boolean; reply?: string; error?: string; elapsed: number }> = {};
@@ -681,9 +725,14 @@ export default function Home() {
setSessionTestResults(map);
}
})
.catch(() => {})
.catch((e) => {
const msg = e instanceof Error ? e.message : "Request failed";
const failed: Record<string, { ok: boolean; error: string; elapsed: number }> = {};
if (data) for (const a of data.agents) failed[a.id] = { ok: false, error: msg, elapsed: 0 };
setSessionTestResults(failed);
})
.finally(() => setTestingSessions(false));
}, [data]);
}, [data, callTestApi]);
const testAllDmSessions = useCallback(() => {
setTestingDmSessions(true);
@@ -696,8 +745,7 @@ export default function Home() {
}
}
setDmSessionResults(pending);
fetch("/api/test-dm-sessions", { method: "POST" })
.then((r) => r.json())
callTestApi("/api/test-dm-sessions")
.then((resp) => {
if (resp.results) {
const map: Record<string, PlatformTestResult> = {};
@@ -705,9 +753,24 @@ export default function Home() {
setDmSessionResults(map);
}
})
.catch(() => {})
.catch((e) => {
const msg = e instanceof Error ? e.message : "Request failed";
const failed: Record<string, PlatformTestResult> = {};
if (data) {
for (const a of data.agents) {
for (const p of a.platforms) {
failed[`${a.id}:${p.name}`] = {
ok: false,
error: msg,
elapsed: 0,
};
}
}
}
setDmSessionResults(failed);
})
.finally(() => setTestingDmSessions(false));
}, [data]);
}, [data, callTestApi]);
// 定时刷新
useEffect(() => {
@@ -837,7 +900,7 @@ export default function Home() {
{/* 卡片墙 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{data.agents.map((agent) => (
<AgentCard key={agent.id} agent={agent} gatewayPort={data.gateway?.port || 18789} gatewayToken={data.gateway?.token} t={t} testResult={testResults?.[agent.id]} platformTestResults={platformTestResults || undefined} sessionTestResult={sessionTestResults?.[agent.id]} agentState={agentStates[agent.id]} dmSessionResults={dmSessionResults || undefined} providerAccessModeMap={providerAccessModeMap} />
<AgentCard key={agent.id} agent={agent} gatewayPort={data.gateway?.port || 18789} gatewayToken={data.gateway?.token} gatewayHost={data.gateway?.host} t={t} testResult={testResults?.[agent.id]} platformTestResults={platformTestResults || undefined} sessionTestResult={sessionTestResults?.[agent.id]} agentState={agentStates[agent.id]} dmSessionResults={dmSessionResults || undefined} providerAccessModeMap={providerAccessModeMap} />
))}
</div>
+8 -7
View File
@@ -18,7 +18,7 @@ import { TILE_SIZE } from '@/lib/pixel-office/constants'
import { TileType, EditTool } from '@/lib/pixel-office/types'
import type { TileType as TileTypeVal, FloorColor, OfficeLayout } from '@/lib/pixel-office/types'
import { getCatalogEntry, isRotatable } from '@/lib/pixel-office/layout/furnitureCatalog'
import { createDefaultLayout, serializeLayout } from '@/lib/pixel-office/layout/layoutSerializer'
import { createDefaultLayout, migrateLayoutColors, serializeLayout } from '@/lib/pixel-office/layout/layoutSerializer'
import { playDoneSound, unlockAudio, setSoundEnabled, isSoundEnabled } from '@/lib/pixel-office/notificationSound'
import { loadCharacterPNGs, loadWallPNG } from '@/lib/pixel-office/sprites/pngLoader'
import { useI18n } from '@/lib/i18n'
@@ -139,7 +139,7 @@ export default function PixelOfficePage() {
const agentStatsRef = useRef<Map<string, { sessionCount: number; messageCount: number; totalTokens: number; todayAvgResponseMs: number; weeklyResponseMs: number[]; weeklyTokens: number[]; lastActive: number | null }>>(new Map())
const contributionsRef = useRef<ContributionData | null>(null)
const photographRef = useRef<HTMLImageElement | null>(null)
const gatewayRef = useRef<{ port: number; token?: string }>({ port: 18789 })
const gatewayRef = useRef<{ port: number; token?: string; host?: string }>({ port: 18789 })
const providersRef = useRef<Array<{ id: string; api: string; models: Array<{ id: string; name: string; contextWindow?: number }>; usedBy: Array<{ id: string; emoji: string; name: string }> }>>([])
const [isEditMode, setIsEditMode] = useState(cachedIsEditMode)
const [soundOn, setSoundOn] = useState(true)
@@ -180,8 +180,9 @@ export default function PixelOfficePage() {
const res = await fetch('/api/pixel-office/layout')
const data = await res.json()
if (data.layout) {
officeRef.current = new OfficeState(data.layout)
savedLayoutRef.current = data.layout
const migrated = migrateLayoutColors(data.layout)
officeRef.current = new OfficeState(migrated)
savedLayoutRef.current = migrated
} else {
officeRef.current = new OfficeState()
}
@@ -477,7 +478,7 @@ export default function PixelOfficePage() {
}
}
agentStatsRef.current = map
if (data.gateway) gatewayRef.current = { port: data.gateway.port || 18789, token: data.gateway.token }
if (data.gateway) gatewayRef.current = { port: data.gateway.port || 18789, token: data.gateway.token, host: data.gateway.host }
if (data.providers) providersRef.current = data.providers
} catch {}
}
@@ -715,8 +716,8 @@ export default function PixelOfficePage() {
// Click on PC — open gateway chat for main agent
const gw = gatewayRef.current
const sessionKey = 'agent:main:main'
let chatUrl = buildGatewayUrl(gw.port, '/chat', { session: sessionKey })
if (gw.token) chatUrl = buildGatewayUrl(gw.port, '/chat', { session: sessionKey, token: gw.token })
let chatUrl = buildGatewayUrl(gw.port, '/chat', { session: sessionKey }, gw.host)
if (gw.token) chatUrl = buildGatewayUrl(gw.port, '/chat', { session: sessionKey, token: gw.token }, gw.host)
window.open(chatUrl, '_blank')
} else if (office.layout.furniture.some(f => {
if (f.uid !== 'library-r') return false
+3 -2
View File
@@ -33,6 +33,7 @@ interface Session {
interface GatewayInfo {
port: number;
token?: string;
host?: string;
}
const TYPE_EMOJI_COLOR: Record<string, { emoji: string; color: string }> = {
@@ -289,8 +290,8 @@ function SessionList({ agentId }: { agentId: string }) {
<div className="space-y-3">
{sessions.map((s) => {
const typeInfo = getTypeLabel(s.type);
let chatUrl = buildGatewayUrl(gateway.port, "/chat", { session: s.key });
if (gateway.token) chatUrl = buildGatewayUrl(gateway.port, "/chat", { session: s.key, token: gateway.token });
let chatUrl = buildGatewayUrl(gateway.port, "/chat", { session: s.key }, gateway.host);
if (gateway.token) chatUrl = buildGatewayUrl(gateway.port, "/chat", { session: s.key, token: gateway.token }, gateway.host);
return (
<div
key={s.key}
+13 -6
View File
@@ -1,11 +1,18 @@
/**
* Build a gateway URL using the current browser hostname instead of
* hard-coded "localhost", so the dashboard works over LAN.
* Falls back to "localhost" during SSR.
* Build a gateway URL.
* - Prefer explicit host override from backend config when provided.
* - Fallback to current browser hostname for LAN access.
* - SSR fallback: localhost.
*/
export function buildGatewayUrl(port: number, path: string, params?: Record<string, string>): string {
const host = typeof window !== "undefined" ? window.location.hostname : "localhost";
const url = new URL(`http://${host}:${port}${path}`);
export function buildGatewayUrl(
port: number,
path: string,
params?: Record<string, string>,
hostOverride?: string,
): string {
const host = (hostOverride && hostOverride.trim()) || (typeof window !== "undefined" ? window.location.hostname : "localhost");
const normalizedHost = host.includes("://") ? new URL(host).hostname : host;
const url = new URL(`http://${normalizedHost}:${port}${path}`);
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v) url.searchParams.set(k, v);
@@ -67,7 +67,6 @@ export function layoutToFurnitureInstances(furniture: PlacedFurniture[]): Furnit
}
}
// Colorize sprite if this furniture has a color override
let sprite = entry.sprite
if (item.color) {
const { h, s, b: bv, c: cv } = item.color