```
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:
@ -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) => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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" />}
|
||||
|
||||
@ -60,7 +60,7 @@ const ACCESS_TOKEN_KEY = 'beaver_access_token';
|
||||
const REFRESH_TOKEN_KEY = 'beaver_refresh_token';
|
||||
export const AUTH_CLEARED_EVENT = 'beaver-auth-cleared';
|
||||
const REQUEST_TIMEOUT_MS = 8000;
|
||||
const OUTLOOK_REQUEST_TIMEOUT_MS = 45000;
|
||||
const OUTLOOK_REQUEST_TIMEOUT_MS = 360000;
|
||||
const SKILL_LEARNING_REQUEST_TIMEOUT_MS = 120000;
|
||||
|
||||
export type PromptLocale = 'zh-Hans' | 'zh-Hant' | 'en';
|
||||
@ -902,10 +902,11 @@ export async function submitSkillDraft(
|
||||
skillName: string,
|
||||
draftId: string,
|
||||
notes: string = ''
|
||||
): Promise<SkillReviewRecord> {
|
||||
): Promise<SkillDraft> {
|
||||
return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/drafts/${encodeURIComponent(draftId)}/submit`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ notes }),
|
||||
timeoutMs: SKILL_LEARNING_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
28
app-instance/frontend/lib/notification-runtime.test.ts
Normal file
28
app-instance/frontend/lib/notification-runtime.test.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
NOTIFICATION_REFRESH_INTERVAL_MS,
|
||||
scheduleNotificationRefresh,
|
||||
} from '@/lib/notification-runtime';
|
||||
|
||||
describe('notification refresh scheduling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('refreshes notifications periodically until cleanup', async () => {
|
||||
const refresh = vi.fn();
|
||||
const cleanup = scheduleNotificationRefresh(refresh);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(NOTIFICATION_REFRESH_INTERVAL_MS);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
|
||||
cleanup();
|
||||
await vi.advanceTimersByTimeAsync(NOTIFICATION_REFRESH_INTERVAL_MS);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
12
app-instance/frontend/lib/notification-runtime.ts
Normal file
12
app-instance/frontend/lib/notification-runtime.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export const NOTIFICATION_REFRESH_INTERVAL_MS = 5_000;
|
||||
|
||||
export function scheduleNotificationRefresh(
|
||||
refresh: () => void | Promise<void>,
|
||||
intervalMs = NOTIFICATION_REFRESH_INTERVAL_MS,
|
||||
): () => void {
|
||||
const timer = setInterval(() => {
|
||||
void refresh();
|
||||
}, intervalMs);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
16
app-instance/frontend/lib/outlook-counts-visibility.test.ts
Normal file
16
app-instance/frontend/lib/outlook-counts-visibility.test.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Outlook count presentation', () => {
|
||||
it('does not render summary count chips or tab count labels', () => {
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), 'app/(app)/outlook/page.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(source).not.toContain('<TopStat');
|
||||
expect(source).not.toContain('view.count');
|
||||
});
|
||||
});
|
||||
29
app-instance/frontend/lib/outlook-page-state.test.ts
Normal file
29
app-instance/frontend/lib/outlook-page-state.test.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { nextOutlookAutoLoadTarget } from '@/lib/outlook-page-state';
|
||||
|
||||
describe('nextOutlookAutoLoadTarget', () => {
|
||||
it('loads the active mailbox once when it has not been attempted', () => {
|
||||
expect(
|
||||
nextOutlookAutoLoadTarget({
|
||||
isConfigured: true,
|
||||
activeView: 'inbox',
|
||||
loaded: { inbox: false, sent: false, calendar: false },
|
||||
loading: { inbox: false, sent: false, calendar: false },
|
||||
attempted: { inbox: false, sent: false, calendar: false },
|
||||
})
|
||||
).toBe('inbox');
|
||||
});
|
||||
|
||||
it('does not auto-retry the same mailbox after a failed attempt', () => {
|
||||
expect(
|
||||
nextOutlookAutoLoadTarget({
|
||||
isConfigured: true,
|
||||
activeView: 'inbox',
|
||||
loaded: { inbox: false, sent: false, calendar: false },
|
||||
loading: { inbox: false, sent: false, calendar: false },
|
||||
attempted: { inbox: true, sent: false, calendar: false },
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
20
app-instance/frontend/lib/outlook-page-state.ts
Normal file
20
app-instance/frontend/lib/outlook-page-state.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export type OutlookAutoLoadView = 'inbox' | 'sent' | 'calendar';
|
||||
|
||||
export interface OutlookAutoLoadState {
|
||||
isConfigured: boolean;
|
||||
activeView: OutlookAutoLoadView | 'settings';
|
||||
loaded: Record<OutlookAutoLoadView, boolean>;
|
||||
loading: Record<OutlookAutoLoadView, boolean>;
|
||||
attempted: Record<OutlookAutoLoadView, boolean>;
|
||||
}
|
||||
|
||||
export function nextOutlookAutoLoadTarget(state: OutlookAutoLoadState): OutlookAutoLoadView | null {
|
||||
if (!state.isConfigured || state.activeView === 'settings') {
|
||||
return null;
|
||||
}
|
||||
const view = state.activeView;
|
||||
if (state.loaded[view] || state.loading[view] || state.attempted[view]) {
|
||||
return null;
|
||||
}
|
||||
return view;
|
||||
}
|
||||
@ -63,6 +63,9 @@ export interface Session {
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
path?: string;
|
||||
source?: string | null;
|
||||
title?: string | null;
|
||||
preview?: string | null;
|
||||
}
|
||||
|
||||
export interface SessionDetail {
|
||||
@ -1028,6 +1031,15 @@ export interface SkillDraft {
|
||||
reviews?: SkillReviewRecord[];
|
||||
safety_report?: SkillDraftSafetyReport | null;
|
||||
eval_report?: SkillDraftEvalReport | null;
|
||||
eval_status?: 'not_started' | 'not_applicable' | 'pending' | 'failed' | 'completed' | 'skipped_provider_unavailable';
|
||||
eval_error?: string | null;
|
||||
eval_progress?: {
|
||||
phase?: 'preparing' | 'replaying' | 'completed' | 'failed';
|
||||
completed_arms?: number;
|
||||
total_arms?: number;
|
||||
completed_cases?: number;
|
||||
total_cases?: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface SkillReviewRecord {
|
||||
|
||||
Reference in New Issue
Block a user