feat(engine): 优化智能体循环中的助手消息处理逻辑

- 在没有工具调用时才添加助手消息到上下文
- 确保工具调用响应正确添加到消息上下文中
- 修复了消息构建的条件逻辑

fix(cron): 改进定时任务调度的时间解析功能

- 添加正则表达式导入用于时间显示解析
- 实现从显示文本中提取毫秒间隔的功能
- 增强整数转换的安全性,避免类型错误
- 优化定时任务配置的解析逻辑

feat(outlook): 增强Outlook集成的功能和稳定性

- 将默认超时时间从10秒增加到180秒
- 为状态检查函数添加可选的验证参数
- 串行执行邮件概览获取操作而非并行
- 改进连接状态验证逻辑

feat(channel): 添加设备名称作为会话标识的选项

- 为终端WebSocket适配器添加新的配置选项
- 实现基于设备名称生成会话对等ID的功能
- 记录原始对等ID和设备名称的元数据
- 支持从设备名称创建会话对等ID

feat(skills): 完善技能学习评估系统和进度跟踪

- 在应用启动时自动调度待评估的技能草稿
- 为技能评估工作创建独立的循环工厂
- 实现异步技能评估任务的取消和清理机制
- 添加技能评估进度报告和状态跟踪功能
- 扩展会话列表API以包含更多详细信息
- 防止对不存在的会话进行操作
- 优化技能草稿提交和评估的业务逻辑

perf(skills): 提升技能评估的并发性能

- 实现并行技能案例评估以提高效率
- 添加最大并行案例数的环境变量控制
- 实现实时评估进度更新和回调机制
- 优化评估过程中的资源管理和同步

refactor(services): 创建隔离的智能体循环实例

- 添加创建独立智能体循环的工厂方法
- 确保新循环继承运行时服务配置
- 支持技能评估等需要隔离环境的场景
```
This commit is contained in:
2026-06-15 14:48:16 +08:00
parent 8aeb97a5fc
commit 4b0bf65ace
53 changed files with 4328 additions and 292 deletions

View File

@ -8,6 +8,7 @@ import { listNotifications } from '@/lib/api';
import type { NotificationRun } from '@/types';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { scheduleNotificationRefresh } from '@/lib/notification-runtime';
import { containedLongTextClass } from '@/lib/text-wrapping';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -19,20 +20,21 @@ export default function NotificationsPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = React.useCallback(async () => {
setLoading(true);
const load = React.useCallback(async (background = false) => {
if (!background) setLoading(true);
setError(null);
try {
setItems(await listNotifications());
} catch (err: any) {
setError(err.message || pickAppText(locale, '加载通知失败', 'Failed to load notifications'));
} finally {
setLoading(false);
if (!background) setLoading(false);
}
}, [locale]);
useEffect(() => {
void load();
return scheduleNotificationRefresh(() => load(true));
}, [load]);
const formatTime = (value?: string | null) => {

View File

@ -57,6 +57,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { AppLocale } from '@/lib/i18n/core';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { nextOutlookAutoLoadTarget, type OutlookAutoLoadView } from '@/lib/outlook-page-state';
type OutlookFormState = OutlookConnectionPayload;
type OutlookView = 'inbox' | 'sent' | 'calendar' | 'settings';
@ -368,6 +369,11 @@ export default function OutlookPage() {
sent: false,
});
const [calendarLoading, setCalendarLoading] = useState(false);
const [autoLoadAttempted, setAutoLoadAttempted] = useState<Record<OutlookAutoLoadView, boolean>>({
inbox: false,
sent: false,
calendar: false,
});
const formDirtyRef = React.useRef(formDirty);
useEffect(() => {
@ -399,6 +405,7 @@ export default function OutlookPage() {
}, [t]);
const loadMailboxPage = useCallback(async (view: OutlookMailboxView, skip = 0) => {
setAutoLoadAttempted((current) => ({ ...current, [view]: true }));
setMailboxLoading((current) => ({ ...current, [view]: true }));
try {
const nextPage = await getOutlookMessages(view === 'inbox' ? 'inbox' : 'sentitems', {
@ -425,6 +432,7 @@ export default function OutlookPage() {
}, [t]);
const loadCalendarPage = useCallback(async (anchorKey: string) => {
setAutoLoadAttempted((current) => ({ ...current, calendar: true }));
setCalendarLoading(true);
try {
const range = buildCalendarRange(anchorKey);
@ -461,9 +469,7 @@ export default function OutlookPage() {
if (!background) {
setStatusLoading(false);
}
if (nextStatus.configured) {
await loadOverview(options?.preserveOverview ?? background);
} else {
if (!nextStatus.configured) {
setOverview(null);
setOverviewLoading(false);
}
@ -523,9 +529,6 @@ export default function OutlookPage() {
);
const isConfigured = Boolean(status?.configured);
const isConnected = Boolean(status?.connected);
const inboxCount = overview?.recentInbox.length ?? 0;
const sentCount = overview?.recentSent.length ?? 0;
const eventCount = overview?.todayEvents.length ?? 0;
const overviewWarnings = overview?.warnings || [];
const testWarnings = testResult?.warnings || [];
const statusPending = statusLoading && !status;
@ -538,7 +541,6 @@ export default function OutlookPage() {
label: t('设置', 'Settings'),
hint: t('配置 Outlook 连接', 'Configure the Outlook connection'),
icon: Settings2,
count: null,
},
];
}
@ -549,31 +551,27 @@ export default function OutlookPage() {
label: t('收件箱', 'Inbox'),
hint: t('最近接收邮件', 'Recently received mail'),
icon: Inbox,
count: null,
},
{
id: 'sent' as const,
label: t('发件箱', 'Sent'),
hint: t('最近发送记录', 'Recently sent messages'),
icon: Send,
count: null,
},
{
id: 'calendar' as const,
label: t('日程', 'Calendar'),
hint: t('未来 7 天', 'Next 7 days'),
icon: CalendarDays,
count: overviewPending ? null : eventCount,
},
{
id: 'settings' as const,
label: t('设置', 'Settings'),
hint: t('连接与状态', 'Connection and status'),
icon: Settings2,
count: null,
},
];
}, [eventCount, inboxCount, isConfigured, overviewPending, sentCount, t]);
}, [isConfigured, t]);
useEffect(() => {
if (!availableViews.some((view) => view.id === activeView)) {
@ -582,20 +580,31 @@ export default function OutlookPage() {
}, [activeView, availableViews]);
useEffect(() => {
if (!isConfigured) {
return;
}
if (activeView === 'inbox' && !inboxPage && !mailboxLoading.inbox) {
const target = nextOutlookAutoLoadTarget({
isConfigured,
activeView,
loaded: {
inbox: Boolean(inboxPage),
sent: Boolean(sentPage),
calendar: Boolean(calendarPage),
},
loading: {
inbox: mailboxLoading.inbox,
sent: mailboxLoading.sent,
calendar: calendarLoading,
},
attempted: autoLoadAttempted,
});
if (target === 'inbox') {
void loadMailboxPage('inbox', 0);
}
if (activeView === 'sent' && !sentPage && !mailboxLoading.sent) {
} else if (target === 'sent') {
void loadMailboxPage('sent', 0);
}
if (activeView === 'calendar' && !calendarPage && !calendarLoading) {
} else if (target === 'calendar') {
void loadCalendarPage(calendarAnchorKey);
}
}, [
activeView,
autoLoadAttempted,
calendarAnchorKey,
calendarLoading,
calendarPage,
@ -638,6 +647,7 @@ export default function OutlookPage() {
setInboxPage(null);
setSentPage(null);
setCalendarPage(null);
setAutoLoadAttempted({ inbox: false, sent: false, calendar: false });
setCalendarAnchorKey(toLocalDateKey(new Date()));
await loadStatus(true, { forceFormSync: true });
setActiveView('inbox');
@ -663,6 +673,7 @@ export default function OutlookPage() {
setInboxPage(null);
setSentPage(null);
setCalendarPage(null);
setAutoLoadAttempted({ inbox: false, sent: false, calendar: false });
setCalendarAnchorKey(toLocalDateKey(new Date()));
setActiveView('settings');
setFormDirty(false);
@ -676,6 +687,7 @@ export default function OutlookPage() {
const refreshOverview = async () => {
await loadStatus(true, { preserveOverview: true });
await loadOverview(true);
if (activeView === 'inbox') {
await loadMailboxPage('inbox', inboxPage?.page.skip ?? 0);
} else if (activeView === 'sent') {
@ -723,13 +735,6 @@ export default function OutlookPage() {
</div>
<div className="flex flex-wrap items-center gap-2">
{isConfigured ? (
<>
<TopStat label={t('收件箱', 'Inbox')} value={String(inboxCount)} loading={overviewPending} />
<TopStat label={t('发件箱', 'Sent')} value={String(sentCount)} loading={overviewPending} />
<TopStat label={t('日程', 'Calendar')} value={String(eventCount)} loading={overviewPending} />
</>
) : null}
<Button variant="outline" size="sm" className="h-11" onClick={() => void refreshOverview()}>
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
{t('刷新', 'Refresh')}
@ -783,9 +788,6 @@ export default function OutlookPage() {
</span>
<div className="text-left">
<p className="text-sm font-semibold">{view.label}</p>
{typeof view.count === 'number' ? (
<p className="text-xs text-muted-foreground">{t(`${view.count}`, `${view.count} items`)}</p>
) : null}
</div>
</div>
</div>
@ -1210,19 +1212,6 @@ function MiniStat({ label, value }: { label: string; value: string }) {
);
}
function TopStat({ label, value, loading = false }: { label: string; value: string; loading?: boolean }) {
return (
<div className="rounded-full border bg-background px-3 py-1 text-sm">
<span className="text-muted-foreground">{label}</span>
{loading ? (
<Skeleton className="ml-2 inline-flex h-4 w-8 align-middle" />
) : (
<span className="ml-2 font-semibold text-foreground">{value}</span>
)}
</div>
);
}
function MessageCard({
title,
icon,

View File

@ -39,7 +39,7 @@ import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
import { buildTaskTimelineView } from '@/lib/task-timeline-view';
import type { ActiveTask, BackendTask, ChatMessage, FileAttachment, SessionUpdatedEvent, WsEvent } from '@/types';
import type { ActiveTask, BackendTask, ChatMessage, FileAttachment, Session, SessionUpdatedEvent, WsEvent } from '@/types';
function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is SessionUpdatedEvent {
return data.type === 'session_updated' && typeof data.session_id === 'string';
@ -149,7 +149,15 @@ export default function ChatPage() {
const loadSessions = useCallback(async () => {
try {
const list = await listSessions();
useChatStore.getState().setSessions(list);
const store = useChatStore.getState();
store.setSessions(list);
const currentSessionId = store.sessionId;
const isOrphanedGeneratedSession =
/^[0-9a-f]{32}$/i.test(currentSessionId) &&
!list.some((session) => session.key === currentSessionId);
if (isOrphanedGeneratedSession) {
store.setSessionId(list[0]?.key || 'web:default');
}
} catch {
// backend may be offline during first render
}
@ -576,7 +584,9 @@ export default function ChatPage() {
});
}, []);
const formatSessionName = (key: string) => {
const formatSessionName = (key: string, session?: Session) => {
const descriptiveName = session?.title?.trim() || session?.preview?.trim();
if (descriptiveName) return descriptiveName;
if (key.startsWith('web:')) {
const id = key.slice(4);
if (id === 'default') return pickAppText(locale, '默认', 'Default');
@ -594,7 +604,12 @@ export default function ChatPage() {
return key;
};
const archiveTargetSessionName = archiveTargetSessionId ? formatSessionName(archiveTargetSessionId) : '';
const archiveTargetSessionName = archiveTargetSessionId
? formatSessionName(
archiveTargetSessionId,
sessions.find((session) => session.key === archiveTargetSessionId)
)
: '';
const renderSessionSidebar = (variant: 'desktop' | 'drawer') => (
<>
@ -618,7 +633,7 @@ export default function ChatPage() {
<p className="px-3 py-4 text-sm text-muted-foreground">{pickAppText(locale, '暂无对话记录', 'No chat history yet')}</p>
)}
{sessions.map((session) => {
const sessionName = formatSessionName(session.key);
const sessionName = formatSessionName(session.key, session);
const isCurrent = session.key === sessionId;
return (

View File

@ -130,6 +130,16 @@ export default function SkillsPage() {
void load();
}, [load]);
useEffect(() => {
if (!drafts.some((draft) => draft.eval_status === 'pending')) return;
const timer = window.setInterval(() => {
void listSkillDrafts()
.then((items) => setDrafts(Array.isArray(items) ? items : []))
.catch(() => null);
}, 5000);
return () => window.clearInterval(timer);
}, [drafts]);
useEffect(() => {
setActiveTab(normalizeSkillsTab(searchParams?.get('tab')));
}, [searchParams]);
@ -825,7 +835,8 @@ function DraftCard({
safety?.suggested_fix,
].filter(Boolean).join('\n');
const safetyBlocksReview = Boolean(safety && (!safety.passed || safety.risk_level === 'critical'));
const submitBlocked = draft.status !== 'draft' || safetyBlocksReview;
const canRetryEval = draft.status === 'in_review' && draft.eval_status === 'failed';
const submitBlocked = (draft.status !== 'draft' && !canRetryEval) || safetyBlocksReview;
const rejectBlocked = !REJECTABLE_DRAFT_STATUSES.has(draft.status);
const canPublishLabel = publishBlocked
? publishBlockReason(draft, t)
@ -912,7 +923,7 @@ function DraftCard({
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" className="h-11" disabled={busy || submitBlocked} onClick={() => void onSubmit()}>
<Send className="mr-2 h-4 w-4" />
{t('送审', 'Submit')}
{canRetryEval ? t('重试评估', 'Retry eval') : t('送审', 'Submit')}
</Button>
<Button variant="outline" size="sm" className="h-11" disabled={busy || rejectBlocked} onClick={() => void onReject()}>
<XCircle className="mr-2 h-4 w-4" />
@ -988,7 +999,12 @@ function DraftCard({
<div className="mt-3 grid min-w-0 gap-3 md:grid-cols-2">
<SafetyReportPanel report={safety} />
<EvalReportPanel report={evalReport} />
<EvalReportPanel
report={evalReport}
status={draft.eval_status}
error={draft.eval_error}
progress={draft.eval_progress}
/>
</div>
</div>
);
@ -1111,10 +1127,55 @@ function lineDiffSummary(baseContent: string, proposedContent: string): { added:
return { added, removed, changed };
}
function EvalReportPanel({ report }: { report?: SkillDraftEvalReport | null }) {
function EvalReportPanel({
report,
status,
error,
progress,
}: {
report?: SkillDraftEvalReport | null;
status?: SkillDraft['eval_status'];
error?: string | null;
progress?: SkillDraft['eval_progress'];
}) {
const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
if (!report) {
if (status === 'pending') {
const completedArms = Math.max(0, Number(progress?.completed_arms || 0));
const totalArms = Math.max(0, Number(progress?.total_arms || 0));
const progressText = totalArms > 0
? t(
`评估正在后台运行:已完成 ${completedArms}/${totalArms} 次回放(共 ${progress?.total_cases || 10} 个案例,每个案例包含 baseline 和 candidate`,
`Evaluation is running: ${completedArms}/${totalArms} replays completed (${progress?.total_cases || 10} cases, each with baseline and candidate).`
)
: t('评估正在准备案例,完成后会自动更新。', 'Evaluation cases are being prepared and will update automatically.');
return (
<ReadablePanel
icon={<Loader2 className="h-4 w-4 animate-spin" />}
title={t('评估报告', 'Eval report')}
empty={progressText}
/>
);
}
if (status === 'failed') {
return (
<ReadablePanel
icon={<BarChart3 className="h-4 w-4 text-destructive" />}
title={t('评估报告', 'Eval report')}
empty={`${t('评估失败,可再次点击送审重试。', 'Evaluation failed. Submit again to retry.')} ${error || ''}`.trim()}
/>
);
}
if (status === 'not_applicable') {
return (
<ReadablePanel
icon={<BarChart3 className="h-4 w-4" />}
title={t('评估报告', 'Eval report')}
empty={t('该草稿没有关联学习候选,不运行 replay eval。', 'This draft has no linked learning candidate, so replay eval does not run.')}
/>
);
}
return (
<ReadablePanel
icon={<BarChart3 className="h-4 w-4" />}