feat: 添加MinIO文件系统支持并优化外部连接器功能
- 添加MinIO用户文件系统配置选项(BEAVER_MINIO_ROOT_USER等) - 更新外部连接器配置结构,包括BASE_URL和认证令牌设置 - 改进connector provider支持更多类型(official, feishu_bot等) - 实现Mistral模型推理模式支持reasoning_effort参数 - 增强外部连接器策略配置和运行时配置管理 - 添加connector bridge事件验证和安全保护机制 - 优化任务路由逻辑,区分simple_chat和new_task场景 - 更新初始技能工具提示配置,分离authoring admin功能
This commit is contained in:
@ -4,6 +4,7 @@ import React from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { getStatus, listSessions, wsManager } from '@/lib/api';
|
||||
import { runtimeBridgeEnabledForPath } from '@/lib/channel-connector-state';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
import type { ProcessWsEvent, SessionUpdatedEvent, WsEvent } from '@/types';
|
||||
|
||||
@ -47,7 +48,7 @@ export function AppRuntimeBridge() {
|
||||
const ingestProcessEvent = useChatStore((state) => state.ingestProcessEvent);
|
||||
const statusCheckCleanupRef = React.useRef<(() => void) | null>(null);
|
||||
const statusCheckInFlightRef = React.useRef(false);
|
||||
const chatRuntimeEnabled = pathname === '/' || pathname.startsWith('/tasks') || pathname.startsWith('/notifications');
|
||||
const chatRuntimeEnabled = runtimeBridgeEnabledForPath(pathname);
|
||||
|
||||
const loadSessions = React.useCallback(async () => {
|
||||
try {
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Bell, Bot, ChevronDown, FolderOpen, ListTodo, LogOut, Mail, MessageSquare, Puzzle, Settings, Store, Wrench } from 'lucide-react';
|
||||
import { Bell, Bot, ChevronDown, FolderOpen, ListTodo, LogOut, Mail, Menu, MessageSquare, Puzzle, Settings, Store, Wrench, X } from 'lucide-react';
|
||||
import { logout } from '@/lib/api';
|
||||
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
@ -69,6 +69,7 @@ const Header = () => {
|
||||
const { locale } = useAppI18n();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
|
||||
const user = useChatStore((s) => s.user);
|
||||
const isAuthLoading = useChatStore((s) => s.isAuthLoading);
|
||||
const setUser = useChatStore((s) => s.setUser);
|
||||
@ -93,108 +94,175 @@ const Header = () => {
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
setMobileMenuOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mobileMenuOpen) return;
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [mobileMenuOpen]);
|
||||
|
||||
const userInitial = (user?.username || user?.email || '?').trim().charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<header className="fixed left-0 right-0 top-0 z-50 border-b border-[#E6E1DE] bg-[#F7F6F5]/95 backdrop-blur">
|
||||
<div className="mx-auto max-w-[1720px] px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid h-16 grid-cols-[minmax(120px,1fr)_auto_minmax(120px,1fr)] items-center gap-4">
|
||||
<Link href="/" className="flex shrink-0 items-center">
|
||||
<span className="font-serif text-[28px] font-semibold leading-none text-[#0B0B0B]">
|
||||
Beaver
|
||||
</span>
|
||||
</Link>
|
||||
const renderNavLinks = (compact = false) =>
|
||||
NAV_ITEMS.map((item) => {
|
||||
const isActive =
|
||||
item.href === '/'
|
||||
? pathname === '/'
|
||||
: item.matchPrefixes?.some((prefix) => pathname.startsWith(prefix)) ?? pathname.startsWith(item.href);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={compact ? () => setMobileMenuOpen(false) : undefined}
|
||||
className={`flex h-11 shrink-0 items-center gap-2 rounded-full text-sm font-medium transition-colors ${
|
||||
compact ? 'justify-start rounded-lg border border-transparent bg-background px-4' : 'px-4'
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: compact
|
||||
? 'text-[#4F4642] hover:border-[#E6E1DE] hover:bg-muted hover:text-[#0B0B0B]'
|
||||
: 'text-[#4F4642] hover:bg-[#F7F5F4] hover:text-[#0B0B0B]'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{navLabel(item.key)}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
<nav className="flex items-center gap-1 rounded-full border border-[#E6E1DE] bg-white px-1.5 py-1 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive =
|
||||
item.href === '/'
|
||||
? pathname === '/'
|
||||
: item.matchPrefixes?.some((prefix) => pathname.startsWith(prefix)) ?? pathname.startsWith(item.href);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex shrink-0 items-center gap-1.5 rounded-full px-4 py-2 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-[#4F4642] hover:bg-[#F7F5F4] hover:text-[#0B0B0B]'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{navLabel(item.key)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<>
|
||||
<header className="fixed left-0 right-0 top-0 z-50 border-b border-[#E6E1DE] bg-[#F7F6F5]/95 backdrop-blur">
|
||||
<div className="mx-auto max-w-[1720px] px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex h-16 items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-[#E6E1DE] bg-white text-[#1D1715] transition-colors hover:bg-[#F7F5F4] 2xl:hidden"
|
||||
aria-label={mobileMenuOpen ? pickAppText(locale, '关闭导航', 'Close navigation') : pickAppText(locale, '打开导航', 'Open navigation')}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-controls="app-primary-mobile-nav"
|
||||
onClick={() => setMobileMenuOpen((open) => !open)}
|
||||
>
|
||||
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
<Link href="/" className="hidden h-11 shrink-0 items-center min-[360px]:flex">
|
||||
<span className="font-serif text-[26px] font-semibold leading-none text-[#0B0B0B] sm:text-[28px]">
|
||||
Beaver
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="hidden items-center gap-1 rounded-full border border-[#E6E1DE] bg-white px-1.5 py-1 shadow-[0_1px_2px_rgba(0,0,0,0.04)] 2xl:flex">
|
||||
{renderNavLinks(false)}
|
||||
</nav>
|
||||
|
||||
<div className="flex min-w-0 items-center justify-end gap-3">
|
||||
<div className="hidden shrink-0 sm:block">
|
||||
<ConnectionDot />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
{user ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-full border border-[#E6E1DE] bg-white px-2 py-1.5 text-sm font-medium text-[#1D1715] transition-colors hover:bg-[#F7F5F4]"
|
||||
>
|
||||
<Avatar className="h-8 w-8 border border-[#E6E1DE]">
|
||||
<AvatarFallback className="bg-primary text-xs font-semibold text-primary-foreground">
|
||||
{userInitial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden max-w-28 truncate sm:block">{user.username}</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 rounded-3xl border-border/70 p-0 shadow-2xl">
|
||||
<div className="overflow-hidden rounded-3xl bg-[linear-gradient(180deg,#F7F5F4,#FFFFFF)]">
|
||||
<div className="border-b border-border/60 px-6 py-5">
|
||||
<p className="truncate text-center text-sm font-medium text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-4 px-6 py-6 text-center">
|
||||
<Avatar className="h-24 w-24 border-4 border-white shadow-sm">
|
||||
<AvatarFallback className="bg-primary text-4xl font-semibold text-primary-foreground">
|
||||
<div className="flex min-w-0 shrink-0 items-center justify-end gap-2 sm:gap-3">
|
||||
<div className="hidden shrink-0 xl:block">
|
||||
<ConnectionDot />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
{user ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-11 w-11 items-center justify-center gap-2 rounded-full border border-[#E6E1DE] bg-white px-1.5 text-sm font-medium text-[#1D1715] transition-colors hover:bg-[#F7F5F4] sm:w-auto sm:justify-start sm:px-2"
|
||||
aria-label={pickAppText(locale, '打开账号菜单', 'Open account menu')}
|
||||
>
|
||||
<Avatar className="h-8 w-8 border border-[#E6E1DE]">
|
||||
<AvatarFallback className="bg-primary text-xs font-semibold text-primary-foreground">
|
||||
{userInitial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="space-y-1">
|
||||
<p className="text-2xl font-semibold tracking-tight text-foreground">
|
||||
{pickAppText(locale, `${user.username},你好!`, `Hi, ${user.username}`)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '当前已登录到你的工作区实例。', 'You are currently signed in to your workspace instance.')}
|
||||
<span className="hidden max-w-28 truncate sm:block">{user.username}</span>
|
||||
<ChevronDown className="hidden h-4 w-4 text-muted-foreground sm:block" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 rounded-3xl border-border/70 p-0 shadow-2xl">
|
||||
<div className="overflow-hidden rounded-3xl bg-[linear-gradient(180deg,#F7F5F4,#FFFFFF)]">
|
||||
<div className="border-b border-border/60 px-6 py-5">
|
||||
<p className="truncate text-center text-sm font-medium text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 bg-white/90 px-4 py-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleLogout}
|
||||
className="h-12 w-full justify-center rounded-2xl text-sm font-semibold"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '退出登录', 'Sign Out')}
|
||||
</Button>
|
||||
<div className="flex flex-col items-center gap-4 px-6 py-6 text-center">
|
||||
<Avatar className="h-24 w-24 border-4 border-white shadow-sm">
|
||||
<AvatarFallback className="bg-primary text-4xl font-semibold text-primary-foreground">
|
||||
{userInitial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="space-y-1">
|
||||
<p className="text-2xl font-semibold tracking-tight text-foreground">
|
||||
{pickAppText(locale, `${user.username},你好!`, `Hi, ${user.username}`)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '当前已登录到你的工作区实例。', 'You are currently signed in to your workspace instance.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 bg-white/90 px-4 py-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleLogout}
|
||||
className="h-12 w-full justify-center rounded-2xl text-sm font-semibold"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '退出登录', 'Sign Out')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : !isAuthLoading ? null : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : !isAuthLoading ? null : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
{mobileMenuOpen && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-x-0 bottom-0 top-16 z-40 bg-black/40 2xl:hidden"
|
||||
aria-label={pickAppText(locale, '关闭导航', 'Close navigation')}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
<nav
|
||||
id="app-primary-mobile-nav"
|
||||
aria-label={pickAppText(locale, '主导航', 'Primary navigation')}
|
||||
className="fixed bottom-0 left-0 top-16 z-[45] isolate w-[min(86vw,320px)] overflow-y-auto border-r border-[#E6E1DE] bg-background text-foreground shadow-[12px_0_32px_rgba(29,23,21,0.24)] animate-in slide-in-from-left-full duration-200 2xl:hidden"
|
||||
>
|
||||
<div className="min-h-full bg-background px-4 py-5">
|
||||
<div className="grid gap-2 bg-background">
|
||||
{renderNavLinks(true)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ export function LanguageSwitcher({ className }: { className?: string }) {
|
||||
type="button"
|
||||
onClick={() => setLocale(option.value)}
|
||||
className={cn(
|
||||
'rounded px-2 py-1 text-xs font-medium transition-colors',
|
||||
'h-11 w-11 rounded text-xs font-medium transition-colors',
|
||||
locale === option.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
|
||||
@ -1,254 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
FileJson,
|
||||
FileOutput,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Link2,
|
||||
ListChecks,
|
||||
Loader2,
|
||||
PanelRightOpen,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Activity, PanelRightOpen, X } from 'lucide-react';
|
||||
|
||||
import { TaskTimeline } from '@/components/task-detail';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { appStatusLabel } from '@/lib/i18n/common';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type {
|
||||
SessionProgressArtifactView,
|
||||
SessionProgressStepView,
|
||||
SessionProgressView,
|
||||
} from '@/lib/session-progress';
|
||||
import type { ProcessArtifact, ProcessRunStatus } from '@/types';
|
||||
|
||||
function formatShortTime(value: string, locale: 'zh-CN' | 'en-US') {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function statusTone(status: ProcessRunStatus) {
|
||||
if (status === 'done') return 'text-[#2F8D50] bg-[#E3F1E7] border-[#B8D9C2]';
|
||||
if (status === 'running') return 'text-[#2F6FCA] bg-[#E7EEF9] border-[#B8CBE8]';
|
||||
if (status === 'error') return 'text-[#8A3A2D] bg-[#F0E5E1] border-[#D9BDB4]';
|
||||
if (status === 'cancelled') return 'text-[#6A5E58] bg-[#ECE8E5] border-[#D8D2CE]';
|
||||
return 'text-[#6A5E58] bg-[#F0ECE9] border-[#D8D2CE]';
|
||||
}
|
||||
|
||||
function StepMarker({ step, index }: { step: SessionProgressStepView; index: number }) {
|
||||
if (step.status === 'done') {
|
||||
return (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#2F8D50] text-white">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (step.status === 'running') {
|
||||
return (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#2F6FCA] text-[11px] font-semibold text-white">
|
||||
{index + 1}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (step.status === 'error') {
|
||||
return (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#8A3A2D] text-white">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#D8D2CE] text-[#6A5E58]">
|
||||
<Circle className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function artifactIcon(type: ProcessArtifact['artifact_type']) {
|
||||
if (type === 'json') return <FileJson className="h-4 w-4" />;
|
||||
if (type === 'image') return <ImageIcon className="h-4 w-4" />;
|
||||
if (type === 'link') return <Link2 className="h-4 w-4" />;
|
||||
if (type === 'markdown' || type === 'text') return <FileText className="h-4 w-4" />;
|
||||
return <FileOutput className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
function ProgressHeader({ view }: { view: SessionProgressView }) {
|
||||
const { locale } = useAppI18n();
|
||||
const percent = view.progress.percent;
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-[#ECE7E3] bg-white px-4 py-4 shadow-[0_8px_24px_rgba(0,0,0,0.04)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#E3F1E7] text-[#2F8D50]">
|
||||
<ListChecks className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="line-clamp-2 text-sm font-semibold text-foreground">{view.title}</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className={`rounded-full border px-2 py-0.5 text-[11px] font-medium ${statusTone(view.status)}`}>
|
||||
{appStatusLabel(view.status, locale)}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{pickAppText(locale, '更新于', 'Updated')} {formatShortTime(view.updatedAt, locale)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span>{view.progress.label}</span>
|
||||
{percent !== null && <span className="font-medium text-foreground">{percent}%</span>}
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-[#ECE8E5]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[#5DB56F] transition-all"
|
||||
style={{ width: `${percent ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view.summary && (
|
||||
<p className="mt-3 line-clamp-3 text-xs leading-5 text-muted-foreground">{view.summary}</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StepList({ steps }: { steps: SessionProgressStepView[] }) {
|
||||
const { locale } = useAppI18n();
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-[#ECE7E3] bg-white px-4 py-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{pickAppText(locale, '运行步骤', 'Run Steps')}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, `${steps.length} 步`, `${steps.length} steps`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.runId} className="grid grid-cols-[24px_1fr] gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<StepMarker step={step} index={index} />
|
||||
{index < steps.length - 1 && <span className="mt-2 h-full min-h-8 w-px bg-[#E6E1DE]" />}
|
||||
</div>
|
||||
<div className="pb-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="line-clamp-2 text-sm font-medium text-foreground">
|
||||
{index + 1}. {step.title}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{step.actorName} · {formatShortTime(step.updatedAt, locale)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] ${statusTone(step.status)}`}>
|
||||
{appStatusLabel(step.status, locale)}
|
||||
</span>
|
||||
</div>
|
||||
{step.description && (
|
||||
<p className="mt-2 line-clamp-3 text-xs leading-5 text-muted-foreground">
|
||||
{step.description}
|
||||
</p>
|
||||
)}
|
||||
{step.status === 'running' && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-[#2F6FCA]">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>{pickAppText(locale, '正在处理', 'In progress')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactRow({ artifact }: { artifact: SessionProgressArtifactView }) {
|
||||
return (
|
||||
<a
|
||||
href={artifact.url || undefined}
|
||||
target={artifact.url ? '_blank' : undefined}
|
||||
rel={artifact.url ? 'noreferrer' : undefined}
|
||||
className="block rounded-lg border border-[#ECE7E3] bg-[#FDFDFC] px-3 py-3 transition-colors hover:bg-[#F7F6F5]"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#ECE8E5] text-[#5F5550]">
|
||||
{artifactIcon(artifact.type)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{artifact.title}</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{artifact.actorName} · {artifact.typeLabel}
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-5 text-muted-foreground">{artifact.preview}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactSection({ view }: { view: SessionProgressView }) {
|
||||
const { locale } = useAppI18n();
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-[#ECE7E3] bg-white px-4 py-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{pickAppText(locale, '生成内容', 'Generated Content')}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, `${view.artifacts.length} 个`, `${view.artifacts.length} items`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{view.artifactTypeSummaries.length > 0 ? (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{view.artifactTypeSummaries.map((item) => (
|
||||
<span
|
||||
key={item.type}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[#E6E1DE] bg-[#F7F6F5] px-2.5 py-1 text-xs text-[#4F4642]"
|
||||
>
|
||||
{artifactIcon(item.type)}
|
||||
<span>{item.label}</span>
|
||||
<span className="font-semibold">{item.count}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '暂时还没有生成内容。', 'No generated content yet.')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{view.artifacts.map((artifact) => (
|
||||
<ArtifactRow key={artifact.artifactId} artifact={artifact} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
import type { TaskTimelineCard } from '@/types';
|
||||
|
||||
function ProgressPanel({
|
||||
view,
|
||||
cards,
|
||||
isLive,
|
||||
onClose,
|
||||
}: {
|
||||
view: SessionProgressView;
|
||||
cards: TaskTimelineCard[];
|
||||
isLive: boolean;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
@ -260,11 +27,14 @@ function ProgressPanel({
|
||||
<h2 className="text-base font-semibold text-foreground">
|
||||
{pickAppText(locale, '当前会话的运行进度', 'Current Session Progress')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '任务列表会自动刷新', 'Task updates refresh automatically')}
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{isLive ? <Activity className="h-3.5 w-3.5" /> : null}
|
||||
{isLive
|
||||
? pickAppText(locale, '任务时间线实时更新', 'Task timeline updates live')
|
||||
: pickAppText(locale, '与任务详情时间线一致', 'Matches the Task detail timeline')}
|
||||
</p>
|
||||
</div>
|
||||
{onClose && (
|
||||
{onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@ -273,28 +43,32 @@ function ProgressPanel({
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1 px-4 py-4">
|
||||
<div className="space-y-4 pb-6">
|
||||
<ProgressHeader view={view} />
|
||||
<StepList steps={view.steps} />
|
||||
<ArtifactSection view={view} />
|
||||
<div className="pb-6">
|
||||
<TaskTimeline cards={cards} isLive={isLive} showHeader={false} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CurrentSessionProgressSidebar({ view }: { view: SessionProgressView }) {
|
||||
export function CurrentSessionProgressSidebar({
|
||||
cards,
|
||||
isLive,
|
||||
}: {
|
||||
cards: TaskTimelineCard[];
|
||||
isLive: boolean;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="hidden h-full w-[380px] shrink-0 border-l border-[#E6E1DE] xl:flex">
|
||||
<ProgressPanel view={view} />
|
||||
<ProgressPanel cards={cards} isLive={isLive} />
|
||||
</aside>
|
||||
|
||||
<button
|
||||
@ -306,7 +80,7 @@ export function CurrentSessionProgressSidebar({ view }: { view: SessionProgressV
|
||||
<PanelRightOpen className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{mobileOpen && (
|
||||
{mobileOpen ? (
|
||||
<div className="fixed inset-0 z-50 xl:hidden">
|
||||
<button
|
||||
type="button"
|
||||
@ -315,10 +89,10 @@ export function CurrentSessionProgressSidebar({ view }: { view: SessionProgressV
|
||||
aria-label={pickAppText(locale, '关闭进度面板', 'Close progress panel')}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 w-[min(92vw,390px)] border-l border-[#E6E1DE] shadow-2xl">
|
||||
<ProgressPanel view={view} onClose={() => setMobileOpen(false)} />
|
||||
<ProgressPanel cards={cards} isLive={isLive} onClose={() => setMobileOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,6 +10,14 @@ import { getTaskCardMessageIndexes, hasVisibleChatContent, normalizedMessageText
|
||||
import { AgentTeamBlock } from '@/components/chat-workbench/AgentTeamBlock';
|
||||
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { containedPreservedLongTextClass } from '@/lib/text-wrapping';
|
||||
@ -58,6 +66,8 @@ function MessageBubble({
|
||||
const textContent = normalizedMessageText(message.content);
|
||||
const [feedbackMode, setFeedbackMode] = React.useState<'accept' | null>(null);
|
||||
const [feedbackComment, setFeedbackComment] = React.useState('');
|
||||
const [confirmAbandonOpen, setConfirmAbandonOpen] = React.useState(false);
|
||||
const feedbackTextareaId = message.run_id ? `feedback-note-${message.run_id}` : undefined;
|
||||
|
||||
return (
|
||||
<div className={`flex gap-3 ${isUser ? 'justify-end' : ''}`}>
|
||||
@ -130,7 +140,7 @@ function MessageBubble({
|
||||
</div>
|
||||
<Link
|
||||
href={`/tasks/${encodeURIComponent(message.task_id)}`}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
className="inline-flex h-11 items-center gap-1 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{pickAppText(locale, '查看任务', 'Open task')}
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
@ -157,7 +167,7 @@ function MessageBubble({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFeedbackMode('accept')}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
className="inline-flex h-11 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ThumbsUp className="h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '接受', 'Accept')}
|
||||
@ -165,15 +175,15 @@ function MessageBubble({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRequestRevision(message.run_id!)}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
className="inline-flex h-11 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<RefreshCcw className="h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '需要修改', 'Revise')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFeedback(message.run_id!, 'abandon')}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onClick={() => setConfirmAbandonOpen(true)}
|
||||
className="inline-flex h-11 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '放弃', 'Abandon')}
|
||||
@ -181,7 +191,11 @@ function MessageBubble({
|
||||
</div>
|
||||
{feedbackMode && (
|
||||
<div className="space-y-2 rounded-md border border-border bg-background p-2">
|
||||
<label htmlFor={feedbackTextareaId} className="sr-only">
|
||||
{pickAppText(locale, '接受反馈备注', 'Acceptance note')}
|
||||
</label>
|
||||
<textarea
|
||||
id={feedbackTextareaId}
|
||||
value={feedbackComment}
|
||||
onChange={(event) => setFeedbackComment(event.target.value)}
|
||||
placeholder={pickAppText(locale, '可选:补充说明...', 'Optional note...')}
|
||||
@ -194,14 +208,14 @@ function MessageBubble({
|
||||
setFeedbackMode(null);
|
||||
setFeedbackComment('');
|
||||
}}
|
||||
className="h-8 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent"
|
||||
className="h-11 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
{pickAppText(locale, '取消', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFeedback(message.run_id!, feedbackMode, feedbackComment.trim() || undefined)}
|
||||
className="h-8 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
className="h-11 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{pickAppText(locale, '提交', 'Submit')}
|
||||
</button>
|
||||
@ -210,6 +224,35 @@ function MessageBubble({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Dialog open={confirmAbandonOpen} onOpenChange={setConfirmAbandonOpen}>
|
||||
<DialogContent className="max-w-[calc(100vw-2rem)] sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{pickAppText(locale, '放弃当前任务?', 'Abandon this task?')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pickAppText(locale, '放弃后会停止等待该任务的验收结果,此操作需要明确确认。', 'This stops waiting for this task acceptance result and requires confirmation.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmAbandonOpen(false)}
|
||||
className="h-11 rounded-md border border-border px-4 text-sm text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
{pickAppText(locale, '取消', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConfirmAbandonOpen(false);
|
||||
onFeedback(message.run_id!, 'abandon');
|
||||
}}
|
||||
className="h-11 rounded-md bg-destructive px-4 text-sm font-medium text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{pickAppText(locale, '确认放弃', 'Confirm abandon')}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{message.feedback_error && (
|
||||
<span className="text-xs text-destructive">{message.feedback_error}</span>
|
||||
)}
|
||||
@ -394,8 +437,8 @@ export function MessageList({
|
||||
})();
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full px-8" viewportRef={viewportRef}>
|
||||
<div className="mx-auto max-w-5xl space-y-8 py-10">
|
||||
<ScrollArea className="h-full px-3 sm:px-5 md:px-8" viewportRef={viewportRef}>
|
||||
<div className="mx-auto max-w-5xl space-y-8 py-6 md:py-10">
|
||||
{visibleMessages.length === 0 && teamGroups.length === 0 && !isThinking && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Bot className="w-12 h-12 mb-4 opacity-50" />
|
||||
|
||||
@ -54,27 +54,27 @@ export function SkillDetailView({
|
||||
}: SkillDetailViewProps) {
|
||||
const readme = stripFrontmatter(content || '');
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-white text-black [--background:0_0%_100%] [--card:0_0%_100%] [--card-foreground:0_0%_0%] [--foreground:0_0%_0%] [--muted-foreground:0_0%_0%] [--popover:0_0%_100%] [--popover-foreground:0_0%_0%] [--secondary-foreground:0_0%_0%]">
|
||||
<div className="border-b border-border p-5">
|
||||
<div className="min-w-0 rounded-lg border border-border bg-white text-black [--background:0_0%_100%] [--card:0_0%_100%] [--card-foreground:0_0%_0%] [--foreground:0_0%_0%] [--muted-foreground:0_0%_0%] [--popover:0_0%_100%] [--popover-foreground:0_0%_0%] [--secondary-foreground:0_0%_0%]">
|
||||
<div className="border-b border-border p-4 sm:p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
{badges}
|
||||
<Badge variant="outline">v{currentVersion || '-'}</Badge>
|
||||
{loadingVersion && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
<h2 className="break-words text-2xl font-semibold tracking-normal">{title}</h2>
|
||||
{summary && <p className="mt-2 max-w-4xl text-sm leading-6 text-muted-foreground">{summary}</p>}
|
||||
{summary && <p className="mt-2 max-w-4xl break-words text-sm leading-6 text-muted-foreground">{summary}</p>}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="p-5">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">{labels.overview}</TabsTrigger>
|
||||
<TabsTrigger value="files">{labels.files}</TabsTrigger>
|
||||
<TabsTrigger value="versions">{labels.versions}</TabsTrigger>
|
||||
<Tabs defaultValue="overview" className="p-4 sm:p-5">
|
||||
<TabsList className="h-auto min-h-11 flex-wrap">
|
||||
<TabsTrigger value="overview" className="min-h-11">{labels.overview}</TabsTrigger>
|
||||
<TabsTrigger value="files" className="min-h-11">{labels.files}</TabsTrigger>
|
||||
<TabsTrigger value="versions" className="min-h-11">{labels.versions}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-5">
|
||||
@ -86,7 +86,7 @@ export function SkillDetailView({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="files" className="mt-5">
|
||||
<div className="grid gap-4 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<div className="grid min-w-0 gap-4 lg:grid-cols-[minmax(0,320px)_minmax(0,1fr)]">
|
||||
<div className="min-h-[420px] rounded-lg border border-border">
|
||||
{files.length === 0 ? (
|
||||
<EmptyPanel icon={<FileText className="h-7 w-7" />} text={labels.noFiles} />
|
||||
@ -96,7 +96,7 @@ export function SkillDetailView({
|
||||
<button
|
||||
key={file.filePath}
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between gap-3 rounded-md px-3 py-2 text-left text-sm transition hover:bg-muted ${
|
||||
className={`flex min-h-11 w-full items-center justify-between gap-3 rounded-md px-3 py-3 text-left text-sm transition hover:bg-muted ${
|
||||
selectedFile?.filePath === file.filePath ? 'bg-muted' : ''
|
||||
}`}
|
||||
onClick={() => onOpenFile(file.filePath)}
|
||||
@ -109,7 +109,7 @@ export function SkillDetailView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-[420px] rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div className="min-w-0 rounded-lg border border-border bg-muted/20 p-4 lg:min-h-[420px]">
|
||||
{loadingFile ? (
|
||||
<div className="flex h-[360px] items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
@ -129,7 +129,7 @@ export function SkillDetailView({
|
||||
<button
|
||||
key={version.version}
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between gap-4 rounded-lg border px-4 py-3 text-left transition hover:bg-muted ${
|
||||
className={`flex min-h-11 w-full flex-col gap-3 rounded-lg border px-4 py-3 text-left transition hover:bg-muted sm:flex-row sm:items-center sm:justify-between sm:gap-4 ${
|
||||
version.version === currentVersion ? 'border-primary bg-muted' : 'border-border'
|
||||
}`}
|
||||
onClick={() => onSelectVersion(version.version)}
|
||||
@ -137,15 +137,15 @@ export function SkillDetailView({
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-mono text-sm">{version.version}</span>
|
||||
<span className="break-all font-mono text-sm">{version.version}</span>
|
||||
{version.version === currentVersion && <Badge variant="secondary">{labels.current}</Badge>}
|
||||
{version.status && <Badge variant="outline">{version.status}</Badge>}
|
||||
</div>
|
||||
{version.changeReason && (
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">{version.changeReason}</p>
|
||||
<p className="mt-1 break-words text-xs text-muted-foreground">{version.changeReason}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-xs text-muted-foreground">
|
||||
<div className="shrink-0 text-left text-xs text-muted-foreground sm:text-right">
|
||||
{version.publishedAt || version.createdAt || ''}
|
||||
{typeof version.totalSize === 'number' && (
|
||||
<div>
|
||||
@ -165,7 +165,7 @@ export function SkillDetailView({
|
||||
function FilePreview({ file, labels }: { file: SkillFileContent; labels: SkillDetailViewProps['labels'] }) {
|
||||
const content = file.content || '';
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="break-all font-mono text-sm font-medium">{file.filePath}</div>
|
||||
<Badge variant="outline">
|
||||
@ -177,7 +177,7 @@ function FilePreview({ file, labels }: { file: SkillFileContent; labels: SkillDe
|
||||
) : isMarkdown(file.filePath, file.contentType) ? (
|
||||
<MarkdownPreview content={stripFrontmatter(content)} />
|
||||
) : (
|
||||
<pre className="max-h-[560px] overflow-auto rounded-md border border-border bg-background p-4 text-xs leading-5">
|
||||
<pre className="max-h-[560px] max-w-full overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-background p-4 text-xs leading-5">
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
@ -187,7 +187,7 @@ function FilePreview({ file, labels }: { file: SkillFileContent; labels: SkillDe
|
||||
|
||||
function MarkdownPreview({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="prose prose-sm max-w-none text-black prose-a:text-black prose-blockquote:text-black prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:text-black prose-headings:text-black prose-headings:tracking-normal prose-li:text-black prose-p:text-black prose-pre:border prose-pre:border-border prose-pre:bg-muted prose-pre:text-black prose-strong:text-black prose-table:text-black prose-td:text-black prose-th:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
<div className="prose prose-sm max-w-none break-words text-black prose-a:text-black prose-blockquote:text-black prose-code:break-words prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:text-black prose-headings:text-black prose-headings:tracking-normal prose-li:text-black prose-p:text-black prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:border prose-pre:border-border prose-pre:bg-muted prose-pre:text-black prose-strong:text-black prose-table:block prose-table:overflow-x-auto prose-table:text-black prose-td:text-black prose-th:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -7,6 +7,7 @@ import { TaskRuntimeStatusBadge, formatTaskRuntimeTime } from '@/components/task
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
@ -96,7 +97,7 @@ function FeedbackButton({
|
||||
const isBusy = actionBusy === type || Boolean(actionBusy?.endsWith(type));
|
||||
|
||||
return (
|
||||
<Button type="button" variant="outline" className="w-full justify-center" disabled={disabled || Boolean(actionBusy)} onClick={onClick}>
|
||||
<Button type="button" variant="outline" className="h-11 w-full justify-center" disabled={disabled || Boolean(actionBusy)} onClick={onClick}>
|
||||
{isBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : icon}
|
||||
{label}
|
||||
</Button>
|
||||
@ -156,6 +157,7 @@ export function TaskAcceptanceControls({
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
const commentId = React.useId();
|
||||
const [localComment, setLocalComment] = React.useState('');
|
||||
const comment = revision ?? localComment;
|
||||
const setComment = onRevisionChange ?? setLocalComment;
|
||||
@ -224,12 +226,16 @@ export function TaskAcceptanceControls({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
disabled={Boolean(recordedFeedback) || isFinalized || !isReadyForAcceptance || Boolean(actionBusy)}
|
||||
placeholder={pickAppText(locale, '需要修改时写下具体要求;接受或放弃可选填说明。', 'Describe requested changes; notes are optional for accept or abandon.')}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={commentId}>{pickAppText(locale, '验收说明', 'Acceptance note')}</Label>
|
||||
<Textarea
|
||||
id={commentId}
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
disabled={Boolean(recordedFeedback) || isFinalized || !isReadyForAcceptance || Boolean(actionBusy)}
|
||||
placeholder={pickAppText(locale, '需要修改时写下具体要求;接受或放弃可选填说明。', 'Describe requested changes; notes are optional for accept or abandon.')}
|
||||
/>
|
||||
</div>
|
||||
<div className={`text-xs text-muted-foreground ${containedPreservedLongTextClass}`}>
|
||||
{pickAppText(locale, '验收将记录到当前任务运行:', 'Acceptance will be recorded on run: ')}
|
||||
<span className="font-mono">{runId || '-'}</span>
|
||||
|
||||
@ -43,17 +43,17 @@ export function TaskLiveHeader({ task, activeLabel, durationMs, reviewTargetId }
|
||||
const showReviewLink = Boolean(reviewTargetId && ['awaiting_acceptance', 'needs_revision'].includes(task.status));
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<header className="sticky top-[65px] z-20 min-w-0 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:px-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Button asChild variant="outline" size="sm" className="h-11">
|
||||
<Link href="/tasks">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '返回任务', 'Back to tasks')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Button asChild variant="ghost" size="sm" className="h-11">
|
||||
<Link href="/">
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '对话', 'Chat')}
|
||||
@ -70,7 +70,7 @@ export function TaskLiveHeader({ task, activeLabel, durationMs, reviewTargetId }
|
||||
)}
|
||||
{activeLabel ? <Badge variant="secondary">{activeLabel}</Badge> : null}
|
||||
{showReviewLink ? (
|
||||
<Button asChild variant="default" size="sm">
|
||||
<Button asChild variant="default" size="sm" className="h-11">
|
||||
<a href={`#${reviewTargetId}`}>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '验收', 'Review')}
|
||||
|
||||
@ -131,7 +131,7 @@ export function TaskSideRail({ task, runs, artifacts, cards }: Props) {
|
||||
const latestAlert = cards.filter(isWarningOrError).sort((a, b) => toTime(b.createdAt) - toTime(a.createdAt))[0] ?? null;
|
||||
|
||||
return (
|
||||
<aside className="space-y-4">
|
||||
<aside className="min-w-0 space-y-4">
|
||||
<Card className="rounded-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, '任务状态', 'Task status')}</CardTitle>
|
||||
@ -230,14 +230,14 @@ export function TaskSideRail({ task, runs, artifacts, cards }: Props) {
|
||||
<div className="mt-1 text-xs text-muted-foreground">{artifact.artifact_type}</div>
|
||||
</div>
|
||||
{href ? (
|
||||
<Button asChild size="sm" variant="outline" className="shrink-0">
|
||||
<Button asChild size="sm" variant="outline" className="h-11 shrink-0">
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{artifact.url ? <ExternalLink className="mr-2 h-3.5 w-3.5" /> : <Download className="mr-2 h-3.5 w-3.5" />}
|
||||
{artifact.url ? pickAppText(locale, '打开', 'Open') : pickAppText(locale, '下载', 'Download')}
|
||||
</a>
|
||||
</Button>
|
||||
) : inlinePayload ? (
|
||||
<Button size="sm" variant="outline" className="shrink-0" onClick={() => downloadInlineArtifact(artifact)}>
|
||||
<Button size="sm" variant="outline" className="h-11 shrink-0" onClick={() => downloadInlineArtifact(artifact)}>
|
||||
<Download className="mr-2 h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '下载', 'Download')}
|
||||
</Button>
|
||||
|
||||
@ -14,26 +14,29 @@ type Props = {
|
||||
isLive: boolean;
|
||||
resultAcceptance?: TaskResultAcceptance;
|
||||
reviewTargetId?: string;
|
||||
showHeader?: boolean;
|
||||
};
|
||||
|
||||
export function TaskTimeline({ cards, isLive, resultAcceptance, reviewTargetId }: Props) {
|
||||
export function TaskTimeline({ cards, isLive, resultAcceptance, reviewTargetId, showHeader = true }: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold">{pickAppText(locale, '时间线', 'Timeline')}</h2>
|
||||
{isLive ? (
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
|
||||
</span>
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '实时更新', 'Live')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{showHeader ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold">{pickAppText(locale, '时间线', 'Timeline')}</h2>
|
||||
{isLive ? (
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
|
||||
</span>
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '实时更新', 'Live')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{cards.length === 0 ? (
|
||||
<Card className="rounded-md border-dashed">
|
||||
|
||||
@ -148,7 +148,7 @@ function TaskResultHistory({ card }: { card: TaskTimelineCardView }) {
|
||||
|
||||
return (
|
||||
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-muted/20 px-3 py-2 text-sm">
|
||||
<summary className="flex cursor-pointer select-none items-center justify-between gap-3 font-medium">
|
||||
<summary className="flex min-h-[44px] cursor-pointer select-none items-center justify-between gap-3 font-medium">
|
||||
<span>{pickAppText(locale, '展开历史版本', 'Show previous versions')}</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</summary>
|
||||
@ -182,7 +182,7 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
|
||||
const shouldRenderResultAcceptance = Boolean(card.type === 'result' && resultAcceptance && card.runId === resultAcceptance.runId);
|
||||
|
||||
return (
|
||||
<Card id={shouldRenderResultAcceptance ? reviewTargetId : undefined} className="scroll-mt-28 overflow-hidden rounded-md">
|
||||
<Card id={shouldRenderResultAcceptance ? reviewTargetId : undefined} className="min-w-0 max-w-full scroll-mt-44 overflow-hidden rounded-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
@ -224,7 +224,7 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
|
||||
|
||||
{card.type === 'result_history' ? <TaskResultHistory card={card} /> : card.details ? (
|
||||
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-muted/20 px-3 py-2 text-xs">
|
||||
<summary className="cursor-pointer select-none font-medium text-muted-foreground">
|
||||
<summary className="flex min-h-[44px] cursor-pointer select-none items-center font-medium text-muted-foreground">
|
||||
{pickAppText(locale, '详情 JSON', 'Details JSON')}
|
||||
</summary>
|
||||
<pre className={`mt-2 max-h-72 overflow-auto text-[11px] leading-5 text-muted-foreground ${containedJsonTextClass}`}>
|
||||
|
||||
@ -41,7 +41,7 @@ export function TaskManagementTabs() {
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-colors',
|
||||
'inline-flex h-11 items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground'
|
||||
|
||||
@ -20,10 +20,10 @@ const buttonVariants = cva(
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
default: 'h-11 px-4 py-2',
|
||||
sm: 'h-11 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
icon: 'h-11 w-11',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@ -38,13 +38,13 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
'fixed left-4 right-4 top-4 z-50 grid max-h-[calc(100vh-2rem)] w-auto max-w-none gap-4 overflow-x-hidden overflow-y-auto rounded-lg border bg-background p-5 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:left-[50%] sm:right-auto sm:top-[50%] sm:w-full sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:p-6 sm:data-[state=closed]:zoom-out-95 sm:data-[state=open]:zoom-in-95 sm:data-[state=closed]:slide-out-to-left-1/2 sm:data-[state=closed]:slide-out-to-top-[48%] sm:data-[state=open]:slide-in-from-left-1/2 sm:data-[state=open]:slide-in-from-top-[48%] [&>*]:min-w-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<DialogPrimitive.Close className="absolute right-3 top-3 flex h-11 w-11 items-center justify-center rounded-full opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
||||
@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-transparent bg-input px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/10 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex h-11 w-full rounded-lg border border-transparent bg-input px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/10 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
'flex h-11 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -11,7 +11,7 @@ const Switch = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
'peer inline-flex h-11 w-14 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent px-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -19,7 +19,7 @@ const Switch = React.forwardRef<
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-7 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
|
||||
@ -14,7 +14,7 @@ const TabsList = React.forwardRef<
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
||||
'inline-flex min-h-11 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
'inline-flex h-11 min-h-11 items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user