feat: refactor HomePage to integrate agents store and update related components

feat: add runtime event handling for providers in ProvidersSection

feat: update routing to include Channels and Agents pages

feat: extend route types and navigation items for Channels and Agents

feat: implement agents store for managing agent data and interactions

fix: update chat store to utilize agents store for agent-related functionality

chore: export agents store from index

fix: enhance runtime types for better event handling

fix: update Vite config to handle dev server URL correctly
This commit is contained in:
duanshuwen
2026-04-18 14:56:32 +08:00
parent dfa4388087
commit ee72cf7261
52 changed files with 6626 additions and 189 deletions

View File

@@ -0,0 +1,366 @@
import assert from 'node:assert/strict';
import http from 'node:http';
import { createRequire } from 'node:module';
import os from 'node:os';
import path from 'node:path';
import { access, mkdtemp, rm } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { _electron as electron } from 'playwright';
const require = createRequire(import.meta.url);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const distMainEntry = path.join(repoRoot, 'dist-electron', 'main', 'main.js');
const electronExecutable = require('electron');
async function ensureBuildArtifacts() {
await access(distMainEntry);
}
async function startUpstreamServer() {
const requests = [];
const server = http.createServer((req, res) => {
const requestUrl = new URL(req.url || '/', 'http://127.0.0.1');
requests.push({
method: req.method || 'GET',
pathname: requestUrl.pathname,
search: requestUrl.search,
});
if (req.method === 'GET' && requestUrl.pathname === '/ingress/api/channels/targets') {
const channelType = requestUrl.searchParams.get('channelType') || '';
const accountId = requestUrl.searchParams.get('accountId') || '';
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({
success: true,
channelType,
accountId: accountId || null,
targets: [
{
value: 'remote-ops-room',
label: 'Remote Ops Room',
description: 'Simulated upstream discovery target',
kind: 'name',
source: 'remote',
channelType,
accountId: accountId || undefined,
},
],
}));
return;
}
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ success: false, error: 'Not found' }));
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve());
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Failed to bind upstream smoke server');
}
return {
baseUrl: `http://127.0.0.1:${address.port}/ingress`,
requests,
async close() {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
},
};
}
async function attachGatewayCollector(page) {
await page.evaluate(() => {
const scope = window;
if (scope.__smokeGatewayCollectorAttached) return;
scope.__smokeGatewayCollectorAttached = true;
scope.__smokeGatewayEvents = [];
window.api.on('gateway:event', (event) => {
scope.__smokeGatewayEvents.push({
...event,
observedAt: new Date().toISOString(),
});
});
});
}
async function waitForIpcBridge(page, timeout = 15000) {
await page.waitForFunction(
() => Boolean(window.api && typeof window.api.invoke === 'function' && typeof window.api.on === 'function'),
{ timeout },
);
}
async function clearGatewayEvents(page) {
await page.evaluate(() => {
window.__smokeGatewayEvents = [];
});
}
async function waitForGatewayEvent(page, predicateSource, timeout = 15000) {
await page.waitForFunction(
(predicateBody) => {
const events = Array.isArray(window.__smokeGatewayEvents) ? window.__smokeGatewayEvents : [];
const predicate = new Function('event', predicateBody);
return events.some((event) => {
try {
return Boolean(predicate(event));
} catch {
return false;
}
});
},
predicateSource,
{ timeout },
);
return page.evaluate((predicateBody) => {
const events = Array.isArray(window.__smokeGatewayEvents) ? window.__smokeGatewayEvents : [];
const predicate = new Function('event', predicateBody);
return events.find((event) => {
try {
return Boolean(predicate(event));
} catch {
return false;
}
}) ?? null;
}, predicateSource);
}
async function invokeIpc(page, channel, ...args) {
return page.evaluate(
async ({ channel, args }) => window.api.invoke(channel, ...args),
{ channel, args },
);
}
async function hostApiFetch(page, pathName, init = {}) {
const method = init.method || 'GET';
const headers = init.headers || {};
const body = typeof init.body === 'undefined'
? null
: typeof init.body === 'string'
? init.body
: JSON.stringify(init.body);
const response = await invokeIpc(page, 'hostapi:fetch', {
path: pathName,
method,
headers,
body,
});
if (response?.success === false || response?.ok === false) {
throw new Error(response?.error || response?.text || `Host API failed for ${method} ${pathName}`);
}
return typeof response?.json !== 'undefined'
? response.json
: typeof response?.data !== 'undefined'
? response.data
: response;
}
async function navigateTo(page, hashPath, headingText) {
await page.evaluate((nextHash) => {
window.location.hash = nextHash;
}, hashPath);
await page.waitForURL(new RegExp(`#${hashPath.replace('/', '\\/')}`), { timeout: 15000 });
await page.getByText(headingText, { exact: false }).first().waitFor({ timeout: 15000 });
}
async function main() {
await ensureBuildArtifacts();
const tempHome = await mkdtemp(path.join(os.tmpdir(), 'zn-ai-agents-smoke-home-'));
const tempUserDataDir = path.join(tempHome, 'user-data');
const upstreamServer = await startUpstreamServer();
const launchEnv = { ...process.env };
delete launchEnv.ELECTRON_RUN_AS_NODE;
let electronApp;
try {
console.log(`[smoke] using temporary HOME: ${tempHome}`);
console.log(`[smoke] using temporary userData: ${tempUserDataDir}`);
console.log(`[smoke] upstream targets server: ${upstreamServer.baseUrl}`);
console.log(`[smoke] electron executable: ${electronExecutable}`);
console.log('[smoke] launching Electron...');
electronApp = await electron.launch({
executablePath: electronExecutable,
args: ['.'],
cwd: repoRoot,
timeout: 30000,
env: {
...launchEnv,
HOME: tempHome,
ZN_AI_USER_DATA_DIR: tempUserDataDir,
ZN_AI_HOST_API_BASE_URL: upstreamServer.baseUrl,
VITE_SERVICE_URL: upstreamServer.baseUrl,
},
});
console.log('[smoke] Electron launched');
console.log('[smoke] waiting for first window...');
const page = await electronApp.firstWindow();
console.log('[smoke] first window acquired');
await page.waitForLoadState('domcontentloaded');
console.log('[smoke] first window DOM ready');
await page.evaluate(() => {
window.sessionStorage.setItem('token', JSON.stringify('smoke-token'));
window.location.hash = '/models';
window.location.reload();
});
console.log('[smoke] login bypass injected');
await page.waitForURL(/#\/models/, { timeout: 15000 });
await page.getByRole('heading', { name: 'AI Providers' }).waitFor({ timeout: 15000 });
await waitForIpcBridge(page);
await attachGatewayCollector(page);
const gatewayDefaultBefore = await invokeIpc(page, 'gateway:rpc', 'provider.getDefault', {});
assert.equal(gatewayDefaultBefore?.accountId ?? null, null, 'expected clean provider default state');
console.log('[smoke] models page loaded');
const providerAccountId = 'smoke-ollama-account';
const providerLabel = 'Smoke Ollama';
await clearGatewayEvents(page);
await hostApiFetch(page, '/api/provider-accounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: {
account: {
id: providerAccountId,
vendorId: 'ollama',
label: providerLabel,
authMode: 'local',
baseUrl: 'http://127.0.0.1:11434',
model: 'llama3.2',
enabled: true,
isDefault: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
},
});
await waitForGatewayEvent(
page,
"return event.type === 'runtime:changed' && event.topics.includes('providers') && event.reason === 'providers:changed';",
);
await clearGatewayEvents(page);
await hostApiFetch(page, '/api/provider-accounts/default', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: { accountId: providerAccountId },
});
await waitForGatewayEvent(
page,
"return event.type === 'runtime:changed' && event.topics.includes('providers') && event.reason === 'providers:changed';",
);
await page.getByText(providerLabel, { exact: false }).waitFor({ timeout: 15000 });
const gatewayDefaultAfter = await invokeIpc(page, 'gateway:rpc', 'provider.getDefault', {});
assert.equal(gatewayDefaultAfter?.accountId, providerAccountId, 'gateway default provider did not update');
console.log('[smoke] provider runtime sync and UI refresh verified');
await navigateTo(page, '/agents', 'Agents');
await clearGatewayEvents(page);
await hostApiFetch(page, '/api/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { name: 'Smoke Agent' },
});
await waitForGatewayEvent(
page,
"return event.type === 'runtime:changed' && event.topics.includes('agents') && event.reason === 'agents:created';",
);
await page.getByText('Smoke Agent', { exact: false }).first().waitFor({ timeout: 15000 });
await page.getByText('Smoke Agent', { exact: false }).first().click();
const agentsSnapshot = await hostApiFetch(page, '/api/agents');
const smokeAgent = Array.isArray(agentsSnapshot?.agents)
? agentsSnapshot.agents.find((agent) => agent.name === 'Smoke Agent')
: null;
assert.ok(smokeAgent?.id, 'expected Smoke Agent to exist after creation');
console.log('[smoke] agents page auto refresh verified');
await invokeIpc(page, 'set-config', 'selectedChannels', [
{
id: 'douyin-account-1',
channelName: '抖音门店号',
channelUrl: 'https://life.douyin.com/?openConversationId=local-fallback-room&accountId=douyin-account-1',
},
]);
await clearGatewayEvents(page);
await hostApiFetch(page, '/api/channels/binding', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: {
channelType: 'douyin',
accountId: 'douyin-account-1',
agentId: smokeAgent.id,
},
});
await waitForGatewayEvent(
page,
"return event.type === 'runtime:changed' && event.topics.includes('channels') && event.topics.includes('channel-targets') && event.reason === 'channels:binding-updated';",
);
await page.getByText('抖音门店号', { exact: false }).first().waitFor({ timeout: 15000 });
console.log('[smoke] agents-side binding summary refresh verified');
await navigateTo(page, '/channels', 'Channels');
await page.getByText('抖音门店号', { exact: false }).first().waitFor({ timeout: 15000 });
await page.getByText('当前归属Smoke Agent', { exact: false }).waitFor({ timeout: 15000 });
console.log('[smoke] channels page binding refresh verified');
await navigateTo(page, '/cron', '定时任务');
const directTargetCatalog = await hostApiFetch(page, '/api/channels/targets?channelType=douyin&accountId=douyin-account-1');
console.log(`[smoke] direct target discovery: ${JSON.stringify(directTargetCatalog)}`);
await page.getByRole('button', { name: '新建任务' }).click();
await page.getByText('新建定时任务').waitFor({ timeout: 15000 });
await page.getByPlaceholder('例如:晨间播报').fill('Smoke Cron Broadcast');
await page.getByPlaceholder('描述任务要执行或广播的内容').fill('Validate remote target discovery and delivery binding.');
await page.getByRole('button', { name: '执行并发送' }).click();
await page.locator('select').nth(1).selectOption('douyin');
await page.locator('select').nth(2).selectOption('douyin-account-1');
await page.getByRole('button', { name: 'Remote Ops Room' }).waitFor({ timeout: 15000 });
await page.getByRole('button', { name: 'Remote Ops Room' }).click();
await page.getByRole('button', { name: '创建任务' }).last().click();
await page.getByText('定时任务已创建。', { exact: false }).waitFor({ timeout: 15000 });
await page.getByText('Smoke Cron Broadcast', { exact: false }).waitFor({ timeout: 15000 });
await page.getByText('remote-ops-room', { exact: false }).first().waitFor({ timeout: 15000 });
const targetRequests = upstreamServer.requests.filter((entry) => entry.pathname === '/ingress/api/channels/targets');
assert.ok(targetRequests.length > 0, 'expected upstream /api/channels/targets to be called');
console.log('[smoke] cron remote target discovery verified');
console.log('[smoke] all checks passed');
} finally {
await electronApp?.close().catch(() => {});
await upstreamServer.close().catch(() => {});
await rm(tempHome, { recursive: true, force: true }).catch(() => {});
}
}
main().catch((error) => {
console.error('[smoke] failed');
console.error(error);
process.exitCode = 1;
});