feat: 添加swarms团队编排功能并优化agent委派系统

- 引入AgentTeamOrchestrator支持多agent协同任务执行
- 增加第三方swarms库依赖并配置git协议替换以改善包管理
- 扩展DelegationManager支持团队任务调度和进度跟踪
- 实现中文bigram分词算法提升中文任务检索准确性
- 调整A2AClient和DelegationManager超时时间从30秒增至600秒
- 优化AgentRunResult状态判断逻辑增加有意义摘要检测
- 修改Dockerfile配置npm仓库镜像地址和git协议映射
- 更新CLI命令行接口支持网关端口配置传递
- 调整提供者超时配置机制增强请求稳定性
- 移除过时的support_group字段简化agent描述符结构
- 增强错误处理和进度事件报告机制改进用户体验
This commit is contained in:
2026-04-14 14:34:23 +08:00
parent fee9007da6
commit cdfc222c9f
85 changed files with 5443 additions and 1392 deletions

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import type { TokenResponse } from '@/types/auth';
import { normalizePortalLocale, pickPortalText } from '@/lib/i18n/core';
import { HttpError, callDeployControl, callInstanceApi, normalizeTokenResponse } from '@/lib/runtime-control';
function errorStatus(error: unknown): number {
@ -18,6 +19,11 @@ function errorDetail(error: unknown): string {
}
export async function POST(request: NextRequest) {
const locale = normalizePortalLocale(
request.cookies.get('nanobot_locale')?.value ||
request.headers.get('accept-language')
);
try {
const body = (await request.json()) as {
username?: string;
@ -27,7 +33,9 @@ export async function POST(request: NextRequest) {
const password = body.password || '';
if (!username || !password) {
return NextResponse.json({ detail: 'username and password are required' }, { status: 400 });
return NextResponse.json({
detail: pickPortalText(locale, '用户名和密码不能为空', 'Username and password are required'),
}, { status: 400 });
}
const routing = await callDeployControl<{
@ -44,7 +52,9 @@ export async function POST(request: NextRequest) {
return NextResponse.json(normalizeTokenResponse(response, routing));
} catch (error) {
const status = errorStatus(error);
const detail = status === 404 || status === 401 ? '用户名或密码错误' : errorDetail(error);
const detail = status === 404 || status === 401
? pickPortalText(locale, '用户名或密码错误', 'Incorrect username or password')
: errorDetail(error);
return NextResponse.json({ detail }, { status: status === 404 ? 401 : status });
}
}

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import type { TokenResponse } from '@/types/auth';
import { normalizePortalLocale, pickPortalText } from '@/lib/i18n/core';
import { HttpError, REGISTER_REQUEST_TIMEOUT_MS, callAuthzService } from '@/lib/runtime-control';
function errorStatus(error: unknown): number {
@ -18,6 +19,11 @@ function errorDetail(error: unknown): string {
}
export async function POST(request: NextRequest) {
const locale = normalizePortalLocale(
request.cookies.get('nanobot_locale')?.value ||
request.headers.get('accept-language')
);
try {
const body = (await request.json()) as {
username?: string;
@ -29,7 +35,9 @@ export async function POST(request: NextRequest) {
const password = body.password || '';
if (!username || !password) {
return NextResponse.json({ detail: 'username and password are required' }, { status: 400 });
return NextResponse.json({
detail: pickPortalText(locale, '用户名和密码不能为空', 'Username and password are required'),
}, { status: 400 });
}
const response = await callAuthzService<TokenResponse>('/portal/register', {

View File

@ -1,9 +1,11 @@
import './globals.css';
import type { Metadata } from 'next';
import { PortalI18nProvider } from '@/lib/i18n/provider';
import { getServerPortalLocale } from '@/lib/i18n/server';
export const metadata: Metadata = {
title: 'Boardware Agent Sandbox Auth Portal',
description: 'Dedicated login and registration portal for Boardware Genius containers.',
description: 'Boardware Agent Sandbox Auth Portal',
icons: {
icon: '/boardware-logo.jpg',
},
@ -14,9 +16,13 @@ export default function RootLayout({
}: {
children: React.ReactNode;
}) {
const locale = getServerPortalLocale();
return (
<html lang="zh-CN">
<body>{children}</body>
<html lang={locale}>
<body>
<PortalI18nProvider initialLocale={locale}>{children}</PortalI18nProvider>
</body>
</html>
);
}

View File

@ -5,9 +5,13 @@ import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
import { buildFrontendHandoffUrl, login, withNext } from '@/lib/auth-client';
import { pickPortalText } from '@/lib/i18n/core';
import { usePortalI18n } from '@/lib/i18n/provider';
export default function LoginPage() {
const { locale } = usePortalI18n();
const searchParams = useSearchParams();
const nextPath = searchParams?.get('next') || '/';
@ -25,7 +29,7 @@ export default function LoginPage() {
const response = await login(username, password);
window.location.replace(buildFrontendHandoffUrl(response, nextPath));
} catch (err) {
setError(err instanceof Error ? err.message : '登录失败,请稍后重试');
setError(err instanceof Error ? err.message : pickPortalText(locale, '登录失败,请稍后重试', 'Sign-in failed. Please try again.'));
} finally {
setLoading(false);
}
@ -33,6 +37,9 @@ export default function LoginPage() {
return (
<main className="portal-page">
<div className="absolute right-5 top-5 z-10">
<LanguageSwitcher />
</div>
<section className="portal-shell">
<div className="portal-brand">
<div className="portal-logo-lockup">
@ -47,47 +54,55 @@ export default function LoginPage() {
<div className="portal-kicker">Auth Portal</div>
<h1 className="portal-title">Boardware Agent Sandbox</h1>
<p className="portal-copy">
URL
{pickPortalText(
locale,
'这个入口只负责鉴权。成功后会把你直接送到为你分配的专属实例 URL后续前后端请求都留在那套容器里。',
'This portal only handles authentication. After sign-in, you are redirected to your dedicated runtime URL and all later requests stay inside that container.'
)}
</p>
<div className="portal-notes">
<div className="portal-note">
<strong></strong>
auth portal
<strong>{pickPortalText(locale, '容器边界', 'Container boundary')}</strong>
{pickPortalText(
locale,
'登录注册先经过独立 auth portal再跳到专属实例。一用户一套前后端容器不变。',
'Authentication happens in this standalone portal first, then the browser jumps into the dedicated runtime. Each user keeps an isolated frontend/backend container pair.'
)}
</div>
<div className="portal-note">
<strong></strong>
<code>{nextPath}</code>
<strong>{pickPortalText(locale, '目标页面', 'Target page')}</strong>
{pickPortalText(locale, '当前登录完成后将回到:', 'After sign-in you will return to:')} <code>{nextPath}</code>
</div>
</div>
</div>
<div className="portal-panel">
<div className="auth-card">
<h1></h1>
<p></p>
<h1>{pickPortalText(locale, '登录', 'Sign In')}</h1>
<p>{pickPortalText(locale, '输入已有账号,认证完成后直接进入目标容器前端。', 'Use an existing account and continue straight into the target runtime UI.')}</p>
<form className="auth-form" onSubmit={handleSubmit}>
<div className="field">
<label htmlFor="username"></label>
<label htmlFor="username">{pickPortalText(locale, '用户名', 'Username')}</label>
<input
id="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
placeholder="例如bwgdi"
placeholder={pickPortalText(locale, '例如bwgdi', 'Example: bwgdi')}
required
/>
</div>
<div className="field">
<label htmlFor="password"></label>
<label htmlFor="password">{pickPortalText(locale, '密码', 'Password')}</label>
<input
id="password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
placeholder="输入密码"
placeholder={pickPortalText(locale, '输入密码', 'Enter password')}
required
/>
</div>
@ -95,12 +110,14 @@ export default function LoginPage() {
<div className="error-text">{error}</div>
<button className="primary-button" type="submit" disabled={loading}>
{loading ? '登录中...' : '登录并进入容器'}
{loading
? pickPortalText(locale, '登录中...', 'Signing in...')
: pickPortalText(locale, '登录并进入容器', 'Sign in and continue')}
</button>
</form>
<div className="auth-footer">
<Link href={withNext('/register', nextPath)}></Link>
{pickPortalText(locale, '还没有账号?', "Don't have an account yet?")} <Link href={withNext('/register', nextPath)}>{pickPortalText(locale, '去注册', 'Create one')}</Link>
</div>
</div>
</div>

View File

@ -5,9 +5,13 @@ import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
import { buildFrontendHandoffUrl, register, withNext } from '@/lib/auth-client';
import { pickPortalText } from '@/lib/i18n/core';
import { usePortalI18n } from '@/lib/i18n/provider';
export default function RegisterPage() {
const { locale } = usePortalI18n();
const searchParams = useSearchParams();
const nextPath = searchParams?.get('next') || '/mcp';
@ -25,12 +29,12 @@ export default function RegisterPage() {
try {
if (password !== confirmPassword) {
throw new Error('两次输入的密码不一致');
throw new Error(pickPortalText(locale, '两次输入的密码不一致', 'Passwords do not match'));
}
const response = await register(username, email, password);
window.location.replace(buildFrontendHandoffUrl(response, nextPath));
} catch (err) {
setError(err instanceof Error ? err.message : '注册失败,请稍后重试');
setError(err instanceof Error ? err.message : pickPortalText(locale, '注册失败,请稍后重试', 'Sign-up failed. Please try again.'));
} finally {
setLoading(false);
}
@ -38,6 +42,9 @@ export default function RegisterPage() {
return (
<main className="portal-page">
<div className="absolute right-5 top-5 z-10">
<LanguageSwitcher />
</div>
<section className="portal-shell">
<div className="portal-brand">
<div className="portal-logo-lockup">
@ -52,72 +59,80 @@ export default function RegisterPage() {
<div className="portal-kicker">Auth Portal</div>
<h1 className="portal-title">Create Runtime</h1>
<p className="portal-copy">
backend URL
{pickPortalText(
locale,
'注册不仅建立登录账号,还会触发专属实例创建和 backend 身份分配。认证完成后会直接进入你的专属 URL。',
'Sign-up not only creates a login account, it also provisions your dedicated runtime and backend identity. After authentication, you go straight into your own URL.'
)}
</p>
<div className="portal-notes">
<div className="portal-note">
<strong></strong>
AuthZ deploy-control backend auth portal
<strong>{pickPortalText(locale, '注册结果', 'Provisioning result')}</strong>
{pickPortalText(
locale,
'AuthZ 会编排 deploy-control 创建实例,并完成 backend 身份初始化auth portal 最后把你转交到该实例前端。',
'AuthZ coordinates deploy-control to create the runtime, initialize backend identity, and then the portal hands the browser over to that frontend.'
)}
</div>
<div className="portal-note">
<strong></strong>
<code>{nextPath}</code>
<strong>{pickPortalText(locale, '目标页面', 'Target page')}</strong>
{pickPortalText(locale, '当前注册完成后将回到:', 'After sign-up you will return to:')} <code>{nextPath}</code>
</div>
</div>
</div>
<div className="portal-panel">
<div className="auth-card">
<h1></h1>
<p> backend </p>
<h1>{pickPortalText(locale, '注册', 'Sign Up')}</h1>
<p>{pickPortalText(locale, '为当前容器创建登录账号,并完成 backend 身份初始化。', 'Create a login account for this runtime and initialize backend identity.')}</p>
<form className="auth-form" onSubmit={handleSubmit}>
<div className="field">
<label htmlFor="username"></label>
<label htmlFor="username">{pickPortalText(locale, '用户名', 'Username')}</label>
<input
id="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
placeholder="例如bwgdi"
placeholder={pickPortalText(locale, '例如bwgdi', 'Example: bwgdi')}
required
/>
</div>
<div className="field">
<label htmlFor="email"></label>
<label htmlFor="email">{pickPortalText(locale, '邮箱', 'Email')}</label>
<input
id="email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
autoComplete="email"
placeholder="例如steven@example.com"
placeholder={pickPortalText(locale, '例如steven@example.com', 'Example: steven@example.com')}
/>
</div>
<div className="field">
<label htmlFor="password"></label>
<label htmlFor="password">{pickPortalText(locale, '密码', 'Password')}</label>
<input
id="password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password"
placeholder="设置密码"
placeholder={pickPortalText(locale, '设置密码', 'Set a password')}
required
/>
</div>
<div className="field">
<label htmlFor="confirmPassword"></label>
<label htmlFor="confirmPassword">{pickPortalText(locale, '确认密码', 'Confirm password')}</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
autoComplete="new-password"
placeholder="再次输入密码"
placeholder={pickPortalText(locale, '再次输入密码', 'Enter the password again')}
required
/>
</div>
@ -125,16 +140,18 @@ export default function RegisterPage() {
<div className="error-text">{error}</div>
<button className="primary-button" type="submit" disabled={loading}>
{loading ? '注册中...' : '注册并进入容器'}
{loading
? pickPortalText(locale, '注册中...', 'Creating account...')
: pickPortalText(locale, '注册并进入容器', 'Create account and continue')}
</button>
</form>
<div className="auth-footer">
<Link href={withNext('/login', nextPath)}></Link>
{pickPortalText(locale, '已有账号?', 'Already have an account?')} <Link href={withNext('/login', nextPath)}>{pickPortalText(locale, '去登录', 'Sign in')}</Link>
</div>
<div className="status-panel">
Portal URL
{pickPortalText(locale, 'Portal 会先调用部署机接口创建实例,再把浏览器跳到实例自己的 URL。', 'The portal first calls the deployment controller to create the runtime, then redirects the browser into the instance URL.')}
</div>
</div>
</div>

View File

@ -0,0 +1,32 @@
'use client';
import { usePortalI18n } from '@/lib/i18n/provider';
const OPTIONS = [
{ value: 'zh-CN', label: 'ZH' },
{ value: 'en-US', label: 'EN' },
] as const;
export function LanguageSwitcher() {
const { locale, setLocale } = usePortalI18n();
return (
<div className="inline-flex items-center gap-1 rounded-full border border-white/15 bg-black/25 p-1 backdrop-blur">
<span className="ml-1 text-[11px] font-medium uppercase tracking-[0.14em] text-white/70">Lang</span>
{OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setLocale(option.value)}
className={`rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${
locale === option.value
? 'bg-white text-slate-900'
: 'text-white/75 hover:text-white'
}`}
>
{option.label}
</button>
))}
</div>
);
}

View File

@ -1,6 +1,7 @@
'use client';
import type { TokenResponse } from '@/types/auth';
import { getCurrentPortalLocale, pickPortalText } from '@/lib/i18n/core';
const REQUEST_TIMEOUT_MS = 8000;
const REGISTER_REQUEST_TIMEOUT_MS = 90000;
@ -28,6 +29,7 @@ function buildApiUrl(path: string): string {
}
async function fetchJSON<T>(path: string, options?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
const locale = getCurrentPortalLocale();
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
@ -52,13 +54,13 @@ async function fetchJSON<T>(path: string, options?: RequestInit, timeoutMs = REQ
} catch {
// keep raw text
}
throw new Error(`接口错误 ${response.status}: ${detail}`);
throw new Error(`${pickPortalText(locale, '接口错误', 'API error')} ${response.status}: ${detail}`);
}
return response.json();
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new Error('请求超时');
throw new Error(pickPortalText(locale, '请求超时', 'Request timed out'));
}
throw error;
} finally {
@ -81,13 +83,14 @@ export async function register(username: string, email: string, password: string
}
export function buildFrontendHandoffUrl(response: TokenResponse, nextPath: string): string {
const locale = getCurrentPortalLocale();
const frontendBaseUrl = getFrontendBaseUrl(response);
if (!frontendBaseUrl) {
throw new Error('后端未返回目标前端地址');
throw new Error(pickPortalText(locale, '后端未返回目标前端地址', 'Backend did not return a target frontend URL'));
}
const handoffCode = response.handoff_code?.trim();
if (!handoffCode) {
throw new Error('后端未返回 handoff code');
throw new Error(pickPortalText(locale, '后端未返回 handoff code', 'Backend did not return a handoff code'));
}
const url = new URL('/handoff', frontendBaseUrl);

View File

@ -0,0 +1,72 @@
export const PORTAL_LOCALE_COOKIE = 'nanobot_locale';
export const PORTAL_LOCALE_STORAGE_KEY = 'nanobot_locale';
export const PORTAL_LOCALES = ['zh-CN', 'en-US'] as const;
export type PortalLocale = (typeof PORTAL_LOCALES)[number];
export function normalizePortalLocale(value?: string | null): PortalLocale {
const probe = value?.trim().toLowerCase() || '';
if (probe.startsWith('en')) {
return 'en-US';
}
return 'zh-CN';
}
function readCookieLocale(): string | null {
if (typeof document === 'undefined') {
return null;
}
const match = document.cookie
.split('; ')
.find((item) => item.startsWith(`${PORTAL_LOCALE_COOKIE}=`));
if (!match) {
return null;
}
return decodeURIComponent(match.slice(PORTAL_LOCALE_COOKIE.length + 1));
}
export function readBrowserPortalLocale(): PortalLocale {
if (typeof window === 'undefined') {
return 'zh-CN';
}
const fromDocument = document.documentElement.lang;
if (fromDocument) {
return normalizePortalLocale(fromDocument);
}
const fromStorage = window.localStorage.getItem(PORTAL_LOCALE_STORAGE_KEY);
if (fromStorage) {
return normalizePortalLocale(fromStorage);
}
const fromCookie = readCookieLocale();
if (fromCookie) {
return normalizePortalLocale(fromCookie);
}
return normalizePortalLocale(window.navigator.language);
}
export function persistPortalLocale(locale: PortalLocale): void {
if (typeof window === 'undefined') {
return;
}
document.documentElement.lang = locale;
window.localStorage.setItem(PORTAL_LOCALE_STORAGE_KEY, locale);
document.cookie = `${PORTAL_LOCALE_COOKIE}=${encodeURIComponent(locale)}; path=/; max-age=31536000; samesite=lax`;
}
export function getCurrentPortalLocale(): PortalLocale {
if (typeof window === 'undefined') {
return 'zh-CN';
}
return readBrowserPortalLocale();
}
export function pickPortalText<T>(locale: PortalLocale, zhValue: T, enValue: T): T {
return locale === 'en-US' ? enValue : zhValue;
}

View File

@ -0,0 +1,57 @@
'use client';
import React from 'react';
import {
type PortalLocale,
persistPortalLocale,
readBrowserPortalLocale,
} from '@/lib/i18n/core';
type PortalI18nContextValue = {
locale: PortalLocale;
setLocale: (locale: PortalLocale) => void;
};
const PortalI18nContext = React.createContext<PortalI18nContextValue | null>(null);
export function PortalI18nProvider({
initialLocale,
children,
}: {
initialLocale: PortalLocale;
children: React.ReactNode;
}) {
const [locale, setLocaleState] = React.useState<PortalLocale>(initialLocale);
React.useEffect(() => {
const browserLocale = readBrowserPortalLocale();
if (browserLocale !== locale) {
setLocaleState(browserLocale);
return;
}
persistPortalLocale(locale);
}, []);
React.useEffect(() => {
persistPortalLocale(locale);
}, [locale]);
const value = React.useMemo<PortalI18nContextValue>(
() => ({
locale,
setLocale: setLocaleState,
}),
[locale]
);
return <PortalI18nContext.Provider value={value}>{children}</PortalI18nContext.Provider>;
}
export function usePortalI18n(): PortalI18nContextValue {
const value = React.useContext(PortalI18nContext);
if (!value) {
throw new Error('usePortalI18n must be used within PortalI18nProvider');
}
return value;
}

View File

@ -0,0 +1,17 @@
import { cookies, headers } from 'next/headers';
import { PORTAL_LOCALE_COOKIE, normalizePortalLocale, type PortalLocale } from '@/lib/i18n/core';
export function getServerPortalLocale(): PortalLocale {
const cookieLocale = cookies().get(PORTAL_LOCALE_COOKIE)?.value;
if (cookieLocale) {
return normalizePortalLocale(cookieLocale);
}
const acceptLanguage = headers().get('accept-language');
if (acceptLanguage) {
return normalizePortalLocale(acceptLanguage);
}
return 'zh-CN';
}

File diff suppressed because one or more lines are too long