feat: refine business chat workflow
This commit is contained in:
@@ -460,7 +460,7 @@ export function ChatInput({
|
||||
<div
|
||||
className={cn(
|
||||
"w-full shrink-0 px-0 pb-0 pt-3 mx-auto transition-all duration-300",
|
||||
isEmpty ? "max-w-5xl" : "max-w-5xl"
|
||||
isEmpty ? "max-w-4xl" : "max-w-4xl"
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -493,21 +493,21 @@ export function ChatInput({
|
||||
disabled={disabled || sending}
|
||||
data-testid={`chat-quick-task-${task.id}`}
|
||||
className={cn(
|
||||
'inline-flex max-w-[220px] items-center gap-1.5 rounded-lg border px-2.5 py-1 text-[12px] font-medium shadow-sm transition-colors',
|
||||
'inline-flex max-w-[220px] items-center gap-1.5 rounded-lg border px-2.5 py-1 text-[12px] font-medium transition-colors',
|
||||
selected
|
||||
? 'border-[#7DBADB] bg-white text-[#075985]'
|
||||
: 'border-slate-200/80 bg-white/70 text-muted-foreground hover:bg-white dark:border-white/10 dark:bg-card dark:hover:bg-white/10',
|
||||
? 'border-[#7DBADB] bg-[#F4FAFD] text-[#075985]'
|
||||
: 'border-slate-200/80 bg-white text-muted-foreground hover:bg-[#F8FBFE] dark:border-white/10 dark:bg-card dark:hover:bg-white/10',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{task.name}</span>
|
||||
</button>
|
||||
<div className="pointer-events-none absolute bottom-full left-0 z-30 mb-2 hidden w-72 rounded-xl border border-black/10 bg-white p-3 text-left shadow-xl group-hover:block dark:border-white/10 dark:bg-card">
|
||||
<div className="pointer-events-none absolute bottom-full left-0 z-30 mb-2 hidden w-72 rounded-lg border border-slate-200/80 bg-white p-3 text-left shadow-[0_18px_48px_rgba(15,23,42,0.14)] group-hover:block dark:border-white/10 dark:bg-card">
|
||||
<div className="text-[13px] font-semibold text-foreground">{task.name}</div>
|
||||
<div className="mt-1 text-[12px] leading-5 text-muted-foreground">
|
||||
{task.description || t('composer.quickTaskHelp')}
|
||||
</div>
|
||||
{skillNames && (
|
||||
<div className="mt-2 rounded-lg bg-slate-50 px-2 py-1.5 text-[11px] leading-4 text-slate-600 dark:bg-white/5 dark:text-slate-300">
|
||||
<div className="mt-2 rounded-md bg-slate-50 px-2 py-1.5 text-[11px] leading-4 text-slate-600 dark:bg-white/5 dark:text-slate-300">
|
||||
{skillNames}
|
||||
</div>
|
||||
)}
|
||||
@@ -519,7 +519,7 @@ export function ChatInput({
|
||||
)}
|
||||
|
||||
{/* Input Container */}
|
||||
<div className={`relative rounded-lg border bg-white/80 px-3 pb-1.5 pt-2.5 shadow-[0_16px_36px_rgba(15,23,42,0.06)] backdrop-blur transition-all dark:bg-card ${dragOver ? 'border-[#0369A1] ring-1 ring-[#0369A1]' : 'border-slate-200/80 dark:border-white/10'}`}>
|
||||
<div className={`relative rounded-lg border bg-white px-3 pb-2 pt-3 shadow-none transition-colors dark:bg-card ${dragOver ? 'border-[#0369A1] ring-1 ring-[#0369A1]' : 'border-slate-200/80 dark:border-white/10'}`}>
|
||||
{selectedKnowledgeDocuments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pb-1.5">
|
||||
{selectedKnowledgeDocuments.map((doc) => (
|
||||
@@ -527,7 +527,7 @@ export function ChatInput({
|
||||
key={doc.id}
|
||||
type="button"
|
||||
onClick={() => toggleKnowledgeDocument(doc.id)}
|
||||
className="inline-flex max-w-[220px] items-center gap-1.5 rounded-lg border border-[#7DBADB]/70 bg-[#EAF5FA] px-2.5 py-1 text-[13px] font-medium text-foreground transition-colors hover:bg-[#DDF0F8]"
|
||||
className="inline-flex max-w-[220px] items-center gap-1.5 rounded-lg border border-[#7DBADB]/70 bg-[#F4FAFD] px-2.5 py-1 text-[13px] font-medium text-foreground transition-colors hover:bg-[#EAF5FA]"
|
||||
title={doc.name}
|
||||
>
|
||||
<BookOpen className="h-3 w-3 shrink-0 text-[#1E3A8A]" />
|
||||
@@ -539,11 +539,11 @@ export function ChatInput({
|
||||
)}
|
||||
|
||||
{/* Text Row — flush-left */}
|
||||
<div className="flex min-h-[48px] items-start text-[15px] font-normal leading-6">
|
||||
<div className="flex min-h-[48px] items-start text-[14px] font-normal leading-6">
|
||||
{selectedQuickTaskPrompt && (
|
||||
<span
|
||||
onClick={() => textareaRef.current?.focus()}
|
||||
className="mt-0 select-none whitespace-nowrap pr-1.5 font-sans text-[15px] font-normal leading-6 text-muted-foreground/60 md:text-[15px]"
|
||||
className="mt-0 select-none whitespace-nowrap pr-1.5 font-sans text-[14px] font-normal leading-6 text-muted-foreground/60"
|
||||
>
|
||||
{selectedQuickTaskPrompt}
|
||||
</span>
|
||||
@@ -563,7 +563,7 @@ export function ChatInput({
|
||||
placeholder={disabled ? t('composer.gatewayDisconnectedPlaceholder') : ''}
|
||||
disabled={disabled}
|
||||
data-testid="chat-composer-input"
|
||||
className="block min-h-[48px] max-h-[240px] min-w-0 flex-1 resize-none border-0 focus-visible:ring-0 focus-visible:ring-offset-0 shadow-none bg-transparent p-0 font-sans text-[15px] font-normal leading-6 placeholder:text-muted-foreground/60 md:text-[15px]"
|
||||
className="block min-h-[48px] max-h-[240px] min-w-0 flex-1 resize-none border-0 bg-transparent p-0 font-sans text-[14px] font-normal leading-6 shadow-none placeholder:text-muted-foreground/60 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
rows={1}
|
||||
/>
|
||||
</div>
|
||||
@@ -587,7 +587,7 @@ export function ChatInput({
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'h-8 rounded-lg px-2 text-[12px] text-muted-foreground transition-colors hover:bg-[#EAF5FA] hover:text-[#075985] dark:hover:bg-white/10',
|
||||
selectedKnowledgeIds.length > 0 && 'bg-[#EAF5FA] text-[#075985] hover:bg-[#DDF0F8]',
|
||||
selectedKnowledgeIds.length > 0 && 'bg-[#F4FAFD] text-[#075985] hover:bg-[#EAF5FA]',
|
||||
)}
|
||||
onClick={() => setKnowledgePickerOpen((open) => !open)}
|
||||
disabled={!knowledgeBaseAvailable || disabled || sending}
|
||||
@@ -629,7 +629,7 @@ export function ChatInput({
|
||||
onClick={() => toggleKnowledgeDocument(doc.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors',
|
||||
selected ? 'bg-[#EAF5FA] text-foreground' : 'hover:bg-black/5 dark:hover:bg-white/5',
|
||||
selected ? 'bg-[#EAF5FA] text-foreground' : 'hover:bg-[#F8FBFE] dark:hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
@@ -674,7 +674,7 @@ export function ChatInput({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2.5 flex items-center justify-between gap-2 text-[11px] text-muted-foreground/60 px-4">
|
||||
<div className="mt-2.5 flex items-center justify-between gap-2 px-1 text-[11px] text-muted-foreground/60">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={cn("w-1.5 h-1.5 rounded-full", gatewayStatus.state === 'running' ? "bg-green-500/80" : "bg-red-500/80")} />
|
||||
<span>
|
||||
@@ -718,7 +718,7 @@ function AttachmentPreview({
|
||||
const isImage = attachment.mimeType.startsWith('image/') && attachment.preview;
|
||||
|
||||
return (
|
||||
<div className="relative group rounded-lg overflow-hidden border border-border">
|
||||
<div className="group relative overflow-hidden rounded-lg border border-slate-200/80 dark:border-white/10">
|
||||
{isImage ? (
|
||||
// Image thumbnail
|
||||
<div className="w-16 h-16">
|
||||
@@ -730,7 +730,7 @@ function AttachmentPreview({
|
||||
</div>
|
||||
) : (
|
||||
// Generic file card
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 max-w-[200px]">
|
||||
<div className="flex max-w-[200px] items-center gap-2 bg-slate-50 px-3 py-2 dark:bg-white/5">
|
||||
<FileIcon mimeType={attachment.mimeType} className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<p className="text-xs font-medium truncate">{attachment.fileName}</p>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { cn } from '@/lib/utils';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import type { RawMessage, AttachedFileMeta } from '@/stores/chat';
|
||||
import { extractText, extractImages, extractToolUse, formatTimestamp } from './message-utils';
|
||||
import { buildBusinessAnswerView, type BusinessAnswerView, type BusinessAnswerTone } from './business-answer';
|
||||
import assistantLogo from '@/assets/logo.svg';
|
||||
|
||||
interface ChatMessageProps {
|
||||
@@ -120,13 +121,13 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-3 group',
|
||||
'group flex gap-3',
|
||||
isUser ? 'flex-row-reverse' : 'flex-row',
|
||||
)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
{!isUser && (
|
||||
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-[#D5E8F3]/80 bg-white/70 shadow-sm dark:border-white/10 dark:bg-slate-900">
|
||||
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-[#D5E8F3]/80 bg-white shadow-none dark:border-white/10 dark:bg-slate-900">
|
||||
<img src={assistantLogo} alt="智念助手" className="h-7 w-7 rounded-lg" />
|
||||
</div>
|
||||
)}
|
||||
@@ -134,7 +135,7 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
{/* Content */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col w-full min-w-0 max-w-[80%] space-y-2',
|
||||
'flex w-full min-w-0 max-w-[84%] flex-col space-y-2',
|
||||
isUser ? 'items-end' : 'items-start',
|
||||
)}
|
||||
>
|
||||
@@ -192,7 +193,7 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
) : (
|
||||
<div
|
||||
key={`local-${i}`}
|
||||
className="w-36 h-36 rounded-xl border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 flex items-center justify-center text-muted-foreground"
|
||||
className="flex h-36 w-36 items-center justify-center rounded-lg border border-slate-200/80 bg-slate-50 text-muted-foreground dark:border-white/10 dark:bg-white/5"
|
||||
>
|
||||
<File className="h-8 w-8" />
|
||||
</div>
|
||||
@@ -253,7 +254,7 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
}
|
||||
if (isImage && !file.preview) {
|
||||
return (
|
||||
<div key={`local-${i}`} className="w-36 h-36 rounded-xl border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 flex items-center justify-center text-muted-foreground">
|
||||
<div key={`local-${i}`} className="flex h-36 w-36 items-center justify-center rounded-lg border border-slate-200/80 bg-slate-50 text-muted-foreground dark:border-white/10 dark:bg-white/5">
|
||||
<File className="h-8 w-8" />
|
||||
</div>
|
||||
);
|
||||
@@ -321,7 +322,7 @@ function ToolStatusBar({
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg border px-3 py-2 text-xs transition-colors',
|
||||
isRunning && 'border-primary/30 bg-primary/5 text-foreground',
|
||||
!isRunning && !isError && 'border-border/50 bg-muted/20 text-muted-foreground',
|
||||
!isRunning && !isError && 'border-slate-200/80 bg-white text-muted-foreground dark:border-white/10 dark:bg-white/5',
|
||||
isError && 'border-destructive/30 bg-destructive/5 text-destructive',
|
||||
)}
|
||||
>
|
||||
@@ -353,7 +354,7 @@ function AssistantHoverBar({ text, timestamp }: { text: string; timestamp?: numb
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full opacity-0 group-hover:opacity-100 transition-opacity duration-200 select-none px-1">
|
||||
<div className="flex w-full select-none items-center justify-between px-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{timestamp ? formatTimestamp(timestamp) : ''}
|
||||
</span>
|
||||
@@ -380,56 +381,164 @@ function MessageBubble({
|
||||
isUser: boolean;
|
||||
isStreaming: boolean;
|
||||
}) {
|
||||
const businessAnswer = isUser ? null : buildBusinessAnswerView(text);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-lg px-4 py-3',
|
||||
'relative rounded-lg px-4 py-3 text-[14px] leading-6',
|
||||
!isUser && 'w-full',
|
||||
isUser
|
||||
? 'bg-[#0369A1] text-white shadow-[0_10px_22px_rgba(3,105,161,0.12)]'
|
||||
: 'border border-slate-200/70 bg-white/70 text-foreground dark:border-white/10 dark:bg-white/5',
|
||||
? 'bg-[#0369A1] text-white shadow-none'
|
||||
: 'border border-slate-200/80 bg-white text-foreground shadow-none dark:border-white/10 dark:bg-card',
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap break-words break-all text-sm">{text}</p>
|
||||
<p className="whitespace-pre-wrap break-words text-[14px] leading-6 [overflow-wrap:anywhere]">{text}</p>
|
||||
) : (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none break-words break-all">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[[rehypeKatex, { strict: false, throwOnError: false, output: 'html' }]]}
|
||||
components={{
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const isInline = !match && !className;
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="bg-background/50 px-1.5 py-0.5 rounded text-sm font-mono break-words break-all" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<pre className="bg-background/50 rounded-lg p-4 overflow-x-auto">
|
||||
<code className={cn('text-sm font-mono', className)} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
a({ href, children }) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-words break-all">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{normalizeLatexDelimiters(text)}
|
||||
</ReactMarkdown>
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-2 h-4 bg-foreground/50 animate-pulse ml-0.5" />
|
||||
<div className="space-y-3">
|
||||
{businessAnswer && <BusinessAnswerPanel view={businessAnswer} />}
|
||||
<div className={cn(
|
||||
'prose prose-sm dark:prose-invert max-w-none break-words [overflow-wrap:anywhere]',
|
||||
businessAnswer && 'border-t border-slate-200/70 pt-3 dark:border-white/10',
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[[rehypeKatex, { strict: false, throwOnError: false, output: 'html' }]]}
|
||||
components={{
|
||||
h1({ children, ...props }) {
|
||||
return (
|
||||
<h1 className="mb-2 mt-1 text-[18px] font-semibold leading-7 text-slate-950 dark:text-slate-50" {...props}>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
},
|
||||
h2({ children, ...props }) {
|
||||
return (
|
||||
<h2 className="mb-2 mt-3 text-[16px] font-semibold leading-6 text-slate-950 dark:text-slate-50" {...props}>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
},
|
||||
h3({ children, ...props }) {
|
||||
return (
|
||||
<h3 className="mb-1.5 mt-2.5 text-[14px] font-semibold leading-6 text-slate-900 dark:text-slate-100" {...props}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
},
|
||||
p({ children, ...props }) {
|
||||
return (
|
||||
<p className="my-2 leading-6 text-slate-700 first:mt-0 last:mb-0 dark:text-slate-200" {...props}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
ul({ children, ...props }) {
|
||||
return (
|
||||
<ul className="my-2 list-disc space-y-1 pl-5 text-slate-700 dark:text-slate-200" {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
ol({ children, ...props }) {
|
||||
return (
|
||||
<ol className="my-2 list-decimal space-y-1 pl-5 text-slate-700 dark:text-slate-200" {...props}>
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
li({ children, ...props }) {
|
||||
return (
|
||||
<li className="pl-1 leading-6 marker:text-slate-400 dark:marker:text-slate-500" {...props}>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
},
|
||||
blockquote({ children, ...props }) {
|
||||
return (
|
||||
<blockquote className="my-3 border-l-2 border-slate-300 bg-slate-50/80 px-3 py-2 text-slate-600 dark:border-white/20 dark:bg-white/[0.04] dark:text-slate-300" {...props}>
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
hr({ ...props }) {
|
||||
return <hr className="my-4 border-slate-200 dark:border-white/10" {...props} />;
|
||||
},
|
||||
table({ children, ...props }) {
|
||||
return (
|
||||
<div
|
||||
data-testid="markdown-table-scroll"
|
||||
className="my-3 max-w-full overflow-x-auto rounded-lg border border-slate-200 bg-white dark:border-white/10 dark:bg-slate-950/20"
|
||||
>
|
||||
<table data-testid="markdown-table" className="w-full min-w-[520px] border-separate border-spacing-0 text-left text-[13px]" {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children, ...props }) {
|
||||
return (
|
||||
<thead className="bg-slate-50 text-slate-700 dark:bg-white/[0.04] dark:text-slate-200" {...props}>
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
},
|
||||
tr({ children, ...props }) {
|
||||
return (
|
||||
<tr className="border-b border-slate-200 last:border-b-0 dark:border-white/10" {...props}>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
th({ children, ...props }) {
|
||||
return (
|
||||
<th className="border-b border-r border-slate-200 bg-transparent px-3 py-2 text-xs font-semibold leading-5 last:border-r-0 dark:border-white/10" {...props}>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
},
|
||||
td({ children, ...props }) {
|
||||
return (
|
||||
<td className="border-b border-r border-slate-200 px-3 py-2 align-top leading-5 text-slate-700 last:border-r-0 dark:border-white/10 dark:text-slate-200" {...props}>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
},
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const isInline = !match && !className;
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="rounded bg-background/50 px-1.5 py-0.5 font-mono text-sm break-words [overflow-wrap:anywhere]" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<pre className="overflow-x-auto rounded-lg bg-background/50 p-4">
|
||||
<code className={cn('text-sm font-mono', className)} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
a({ href, children }) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="break-words text-primary hover:underline [overflow-wrap:anywhere]">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{normalizeLatexDelimiters(text)}
|
||||
</ReactMarkdown>
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-2 h-4 bg-foreground/50 animate-pulse ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -437,6 +546,88 @@ function MessageBubble({
|
||||
);
|
||||
}
|
||||
|
||||
function getBusinessAnswerToneClasses(tone: BusinessAnswerTone): { panel: string; icon: string; label: string } {
|
||||
if (tone === 'danger') {
|
||||
return {
|
||||
panel: 'border-rose-200 bg-rose-50 text-rose-950 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100',
|
||||
icon: 'bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-100',
|
||||
label: 'text-rose-700 dark:text-rose-200',
|
||||
};
|
||||
}
|
||||
if (tone === 'warning') {
|
||||
return {
|
||||
panel: 'border-amber-200 bg-amber-50 text-amber-950 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100',
|
||||
icon: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-100',
|
||||
label: 'text-amber-700 dark:text-amber-200',
|
||||
};
|
||||
}
|
||||
if (tone === 'success') {
|
||||
return {
|
||||
panel: 'border-emerald-200 bg-emerald-50 text-emerald-950 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100',
|
||||
icon: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-100',
|
||||
label: 'text-emerald-700 dark:text-emerald-200',
|
||||
};
|
||||
}
|
||||
return {
|
||||
panel: 'border-sky-200 bg-sky-50 text-sky-950 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-100',
|
||||
icon: 'bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-100',
|
||||
label: 'text-sky-700 dark:text-sky-200',
|
||||
};
|
||||
}
|
||||
|
||||
function BusinessAnswerPanel({ view }: { view: BusinessAnswerView }) {
|
||||
const classes = getBusinessAnswerToneClasses(view.tone);
|
||||
const Icon = view.tone === 'success' ? CheckCircle2 : AlertCircle;
|
||||
const hasEvidence = view.evidence.length > 0;
|
||||
const hasActions = view.actions.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="business-answer-panel"
|
||||
className={cn('rounded-lg border px-3 py-3 text-sm shadow-none', classes.panel)}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className={cn('mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md', classes.icon)}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className={cn('text-[12px] font-semibold', classes.label)}>
|
||||
业务摘要
|
||||
</div>
|
||||
<div className="mt-0.5 break-words text-sm font-semibold leading-5">
|
||||
{view.status}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(hasEvidence || hasActions) && (
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
{hasEvidence && (
|
||||
<BusinessAnswerList title="依据/影响" items={view.evidence} />
|
||||
)}
|
||||
{hasActions && (
|
||||
<BusinessAnswerList title="下一步" items={view.actions} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BusinessAnswerList({ title, items }: { title: string; items: string[] }) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-md bg-white/70 px-2.5 py-2 dark:bg-white/5">
|
||||
<div className="text-[11px] font-medium text-current/65">{title}</div>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{items.map((item) => (
|
||||
<li key={item} className="line-clamp-2 break-words text-xs leading-5 text-current/85">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── File Card (for user-uploaded non-image files) ───────────────
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -463,10 +654,10 @@ function FileCard({ file }: { file: AttachedFileMeta }) {
|
||||
}, [file.filePath]);
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xl border border-black/10 dark:border-white/10 px-3 py-2.5 bg-black/5 dark:bg-white/5 max-w-[220px]",
|
||||
file.filePath && "cursor-pointer hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
||||
"flex max-w-[220px] items-center gap-3 rounded-lg border border-slate-200/80 bg-slate-50 px-3 py-2.5 dark:border-white/10 dark:bg-white/5",
|
||||
file.filePath && "cursor-pointer transition-colors hover:bg-white dark:hover:bg-white/10"
|
||||
)}
|
||||
onClick={handleOpen}
|
||||
title={file.filePath ? "Open file" : undefined}
|
||||
@@ -502,7 +693,7 @@ function ImageThumbnail({
|
||||
void filePath; void base64; void mimeType;
|
||||
return (
|
||||
<div
|
||||
className="relative w-36 h-36 rounded-xl border overflow-hidden border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 group/img cursor-zoom-in"
|
||||
className="group/img relative h-36 w-36 cursor-zoom-in overflow-hidden rounded-lg border border-slate-200/80 bg-slate-50 dark:border-white/10 dark:bg-white/5"
|
||||
onClick={onPreview}
|
||||
>
|
||||
<img src={src} alt={fileName} className="w-full h-full object-cover" />
|
||||
@@ -533,7 +724,7 @@ function ImagePreviewCard({
|
||||
void filePath; void base64; void mimeType;
|
||||
return (
|
||||
<div
|
||||
className="relative max-w-xs rounded-xl border overflow-hidden border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 group/img cursor-zoom-in"
|
||||
className="group/img relative max-w-xs cursor-zoom-in overflow-hidden rounded-lg border border-slate-200/80 bg-slate-50 dark:border-white/10 dark:bg-white/5"
|
||||
onClick={onPreview}
|
||||
>
|
||||
<img src={src} alt={fileName} className="block w-full" />
|
||||
@@ -628,7 +819,7 @@ function ToolCard({ name, input }: { name: string; input: unknown }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 text-[14px]">
|
||||
<div className="rounded-lg border border-slate-200/80 bg-slate-50 text-[14px] dark:border-white/10 dark:bg-white/5">
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function ChatToolbar() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
className="h-8 w-8 rounded-lg text-muted-foreground transition-colors hover:bg-[#EAF5FA] hover:text-[#075985] dark:hover:bg-white/10"
|
||||
onClick={() => refresh()}
|
||||
disabled={loading}
|
||||
>
|
||||
|
||||
@@ -59,7 +59,7 @@ function StepDetailCard({ step }: { step: TaskStep }) {
|
||||
'min-w-0 flex-1 text-muted-foreground',
|
||||
isTool || isNarration || isThinking
|
||||
? 'px-0 py-0'
|
||||
: 'rounded-xl border border-black/10 bg-white/40 px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]',
|
||||
: 'rounded-md bg-slate-50/70 px-3 py-2 dark:bg-white/[0.03]',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
@@ -96,7 +96,7 @@ function StepDetailCard({ step }: { step: TaskStep }) {
|
||||
</p>
|
||||
)}
|
||||
{!hideStatusText && !showRunningDots && (
|
||||
<span className="rounded-full bg-black/5 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground dark:bg-white/10">
|
||||
<span className="rounded-md bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-muted-foreground dark:bg-white/10">
|
||||
{t(`taskPanel.stepStatus.${step.status}`)}
|
||||
</span>
|
||||
)}
|
||||
@@ -104,7 +104,7 @@ function StepDetailCard({ step }: { step: TaskStep }) {
|
||||
<AnimatedDots className="text-[14px]" />
|
||||
)}
|
||||
{step.depth > 1 && (
|
||||
<span className="rounded-full bg-black/5 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground dark:bg-white/10">
|
||||
<span className="rounded-md bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-muted-foreground dark:bg-white/10">
|
||||
{t('executionGraph.branchLabel')}
|
||||
</span>
|
||||
)}
|
||||
@@ -135,7 +135,7 @@ function StepDetailCard({ step }: { step: TaskStep }) {
|
||||
formatted = JSON.stringify(JSON.parse(step.detail), null, 2);
|
||||
} catch { /* not valid JSON */ }
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border border-black/10 bg-black/[0.03] px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="mt-3 rounded-md bg-slate-50 px-3 py-2 dark:bg-white/[0.03]">
|
||||
<pre
|
||||
className="whitespace-pre-wrap text-[12px] leading-5 text-muted-foreground"
|
||||
>
|
||||
@@ -145,7 +145,7 @@ function StepDetailCard({ step }: { step: TaskStep }) {
|
||||
);
|
||||
})()}
|
||||
{step.detail && expanded && canExpand && (isNarration || isThinking) && (
|
||||
<div className="mt-3 rounded-lg border border-black/10 bg-black/[0.03] px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="mt-3 rounded-md bg-slate-50 px-3 py-2 dark:bg-white/[0.03]">
|
||||
<pre
|
||||
className="whitespace-pre-wrap break-words text-[12px] leading-5 text-muted-foreground"
|
||||
>
|
||||
@@ -197,7 +197,7 @@ export function ExecutionGraphCard({
|
||||
data-testid="chat-execution-graph"
|
||||
data-collapsed="true"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="group flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[12px] text-muted-foreground transition-colors hover:bg-black/5 hover:text-muted-foreground dark:hover:bg-white/5"
|
||||
className="group flex w-full items-center gap-2 rounded-md bg-transparent px-2 py-1.5 text-left text-[12px] text-muted-foreground transition-colors hover:bg-slate-100/70 hover:text-muted-foreground dark:hover:bg-white/5"
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 transition-transform group-hover:translate-x-0.5" />
|
||||
<span className="truncate">
|
||||
@@ -211,13 +211,13 @@ export function ExecutionGraphCard({
|
||||
<div
|
||||
data-testid="chat-execution-graph"
|
||||
data-collapsed="false"
|
||||
className="w-full px-0 py-0 text-muted-foreground"
|
||||
className="w-full rounded-md bg-transparent px-2 py-1 text-muted-foreground"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chat-execution-graph-collapse"
|
||||
onClick={() => setExpanded(false)}
|
||||
className="group flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[12px] text-muted-foreground transition-colors hover:bg-black/5 hover:text-muted-foreground dark:hover:bg-white/5"
|
||||
className="group flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[12px] text-muted-foreground transition-colors hover:bg-[#F8FBFE] hover:text-muted-foreground dark:hover:bg-white/5"
|
||||
aria-label={t('executionGraph.collapseAction')}
|
||||
title={t('executionGraph.collapseAction')}
|
||||
>
|
||||
|
||||
112
src/pages/Chat/business-answer.ts
Normal file
112
src/pages/Chat/business-answer.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
export type BusinessAnswerTone = 'success' | 'warning' | 'danger' | 'info';
|
||||
|
||||
export interface BusinessAnswerView {
|
||||
tone: BusinessAnswerTone;
|
||||
status: string;
|
||||
evidence: string[];
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
const STATUS_LABELS = new Set(['状态', '结论', '当前状态', '处理结果', 'result', 'status', 'conclusion']);
|
||||
const EVIDENCE_LABELS = new Set(['依据', '证据', '发现', '影响', '影响范围', '数据', '原因', '明细', 'risk', 'impact', 'evidence', 'finding']);
|
||||
const ACTION_LABELS = new Set(['下一步', '建议', '处理建议', '需处理', '待确认', '执行预览', '已执行', 'next step', 'action', 'recommendation']);
|
||||
|
||||
function cleanLine(line: string): string {
|
||||
return line
|
||||
.trim()
|
||||
.replace(/^#{1,6}\s*/, '')
|
||||
.replace(/^[-*+]\s+/, '')
|
||||
.replace(/^\d+[.)、]\s+/, '')
|
||||
.replace(/^\*\*(.*)\*\*$/, '$1')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseLabelLine(line: string): { label: string; value: string } | null {
|
||||
const cleaned = cleanLine(line);
|
||||
const match = /^([^::]{2,18})[::]\s*(.+)$/.exec(cleaned);
|
||||
if (!match) return null;
|
||||
return {
|
||||
label: match[1].trim().toLowerCase(),
|
||||
value: match[2].replace(/\*\*$/g, '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function getTone(text: string): BusinessAnswerTone {
|
||||
if (/失败|错误|异常|需处理|未完成|登录态失效|超时|failed|error|needs action/i.test(text)) return 'danger';
|
||||
if (/待确认|需复核|风险|建议|部分|warning|review|pending/i.test(text)) return 'warning';
|
||||
if (/已完成|成功|正常|无异常|success|completed|ok/i.test(text)) return 'success';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
function uniqueItems(items: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
return items
|
||||
.map((item) => item.replace(/\s+/g, ' ').trim())
|
||||
.filter((item) => {
|
||||
if (!item || seen.has(item)) return false;
|
||||
seen.add(item);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function looksBusinessLike(text: string, labelCount: number): boolean {
|
||||
if (labelCount >= 2) return true;
|
||||
if (labelCount < 1) return false;
|
||||
return /房态|渠道|改价|库存|订单|退款|财务|报表|诊断|异常|影响|建议|下一步|待确认|执行|business|report|diagnosis|inventory|order/i.test(text);
|
||||
}
|
||||
|
||||
export function buildBusinessAnswerView(text: string): BusinessAnswerView | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || trimmed.length < 12) return null;
|
||||
|
||||
const evidence: string[] = [];
|
||||
const actions: string[] = [];
|
||||
let status = '';
|
||||
let labelCount = 0;
|
||||
|
||||
for (const rawLine of trimmed.split(/\r?\n/)) {
|
||||
const line = cleanLine(rawLine);
|
||||
if (!line) continue;
|
||||
|
||||
const parsed = parseLabelLine(line);
|
||||
if (parsed) {
|
||||
labelCount += 1;
|
||||
if (STATUS_LABELS.has(parsed.label)) {
|
||||
status ||= parsed.value;
|
||||
continue;
|
||||
}
|
||||
if (ACTION_LABELS.has(parsed.label)) {
|
||||
actions.push(parsed.value);
|
||||
continue;
|
||||
}
|
||||
if (EVIDENCE_LABELS.has(parsed.label)) {
|
||||
evidence.push(parsed.value);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (/^(请|需要|建议|下一步|待确认|先|然后|action|next)/i.test(line)) {
|
||||
actions.push(line);
|
||||
} else if (/^(依据|证据|影响|发现|原因|数据|risk|impact|because)/i.test(line)) {
|
||||
evidence.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (!looksBusinessLike(trimmed, labelCount)) return null;
|
||||
|
||||
if (!status) {
|
||||
const firstLine = cleanLine(trimmed.split(/\r?\n/).find((line) => cleanLine(line)) || '');
|
||||
status = firstLine.replace(/^状态[::]\s*/, '').trim();
|
||||
}
|
||||
|
||||
const cleanedEvidence = uniqueItems(evidence).slice(0, 3);
|
||||
const cleanedActions = uniqueItems(actions).slice(0, 3);
|
||||
if (!status && cleanedEvidence.length === 0 && cleanedActions.length === 0) return null;
|
||||
|
||||
return {
|
||||
tone: getTone(trimmed),
|
||||
status: status || '已整理业务结果',
|
||||
evidence: cleanedEvidence,
|
||||
actions: cleanedActions,
|
||||
};
|
||||
}
|
||||
@@ -605,21 +605,21 @@ export function Chat() {
|
||||
}, [userRunCards, messages, currentSessionKey]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden rounded-lg border border-slate-200/70 bg-white/40 p-4 transition-colors duration-500 dark:border-white/10 dark:bg-slate-950/50")}>
|
||||
<div className={cn("relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden rounded-lg border border-slate-200/80 bg-white text-slate-950 shadow-none transition-colors duration-300 dark:border-white/10 dark:bg-card dark:text-slate-50")}>
|
||||
{/* Toolbar */}
|
||||
<div className="flex shrink-0 items-center justify-end pb-3">
|
||||
<div className="flex shrink-0 items-center justify-end border-b border-slate-200/70 px-4 py-3 dark:border-white/10">
|
||||
<ChatToolbar />
|
||||
</div>
|
||||
|
||||
{/* Messages Area */}
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-hidden pb-4">
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-hidden bg-[#F8FBFE]/70 px-4 py-4 dark:bg-slate-950/30">
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-4 lg:flex-row lg:items-stretch">
|
||||
<div ref={scrollRef} className="min-h-0 min-w-0 flex-1 overflow-y-auto">
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn(
|
||||
"space-y-4 transition-all duration-300",
|
||||
isEmpty ? "mx-auto w-full max-w-5xl" : "mx-auto w-full max-w-5xl",
|
||||
isEmpty ? "mx-auto w-full max-w-4xl" : "mx-auto w-full max-w-4xl",
|
||||
)}
|
||||
>
|
||||
{isEmpty ? (
|
||||
@@ -735,8 +735,8 @@ export function Chat() {
|
||||
|
||||
{/* Error bar */}
|
||||
{visibleError && (
|
||||
<div className="px-4 py-2 bg-destructive/10 border-t border-destructive/20">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between">
|
||||
<div className="border-t border-destructive/20 bg-destructive/10 px-4 py-2">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between">
|
||||
<p className="text-sm text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{visibleError}
|
||||
@@ -764,8 +764,8 @@ export function Chat() {
|
||||
|
||||
{/* Transparent loading overlay */}
|
||||
{minLoading && !currentSessionRunning && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/20 backdrop-blur-[1px] rounded-xl pointer-events-auto">
|
||||
<div className="bg-background shadow-lg rounded-full p-2.5 border border-border">
|
||||
<div className="pointer-events-auto absolute inset-0 z-50 flex items-center justify-center rounded-lg bg-background/20 backdrop-blur-[1px]">
|
||||
<div className="rounded-lg border border-border bg-background p-2.5 shadow-sm">
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -780,9 +780,9 @@ function WelcomeScreen() {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
return (
|
||||
<div className="flex h-[60vh] flex-col items-center justify-center text-center">
|
||||
<div className="max-w-2xl px-8 py-9">
|
||||
<h1 className="text-3xl font-semibold tracking-normal text-foreground/80 md:text-4xl">
|
||||
<div className="flex h-[58vh] flex-col items-center justify-center text-center">
|
||||
<div className="max-w-xl px-8 py-8">
|
||||
<h1 className="text-2xl font-semibold tracking-normal text-slate-950 dark:text-slate-50">
|
||||
{t('welcome.subtitle')}
|
||||
</h1>
|
||||
</div>
|
||||
@@ -795,10 +795,10 @@ function WelcomeScreen() {
|
||||
function TypingIndicator() {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-[#D5E8F3]/80 bg-white/70 text-[#075985] dark:border-white/10 dark:bg-white/5 dark:text-blue-100">
|
||||
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-[#D5E8F3]/80 bg-white text-[#075985] dark:border-white/10 dark:bg-white/5 dark:text-blue-100">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200/70 bg-white/70 px-4 py-3 text-foreground dark:border-white/10 dark:bg-white/5">
|
||||
<div className="rounded-lg border border-slate-200/80 bg-white px-4 py-3 text-foreground shadow-none dark:border-white/10 dark:bg-card">
|
||||
<div className="flex gap-1">
|
||||
<span className="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<span className="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
@@ -815,10 +815,10 @@ function ActivityIndicator({ phase }: { phase: 'tool_processing' }) {
|
||||
void phase;
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-[#D5E8F3]/80 bg-white/70 text-[#075985] dark:border-white/10 dark:bg-white/5 dark:text-blue-100">
|
||||
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-[#D5E8F3]/80 bg-white text-[#075985] dark:border-white/10 dark:bg-white/5 dark:text-blue-100">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200/70 bg-white/70 px-4 py-3 text-foreground dark:border-white/10 dark:bg-white/5">
|
||||
<div className="rounded-lg border border-slate-200/80 bg-white px-4 py-3 text-foreground shadow-none dark:border-white/10 dark:bg-card">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
|
||||
<span>Processing tool results…</span>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* message content formats returned by the Gateway.
|
||||
*/
|
||||
import type { RawMessage, ContentBlock } from '@/stores/chat';
|
||||
import { stripBusinessResponseGuidance } from '../../../shared/business-guidance';
|
||||
|
||||
/**
|
||||
* Clean Gateway metadata from user message text for display.
|
||||
@@ -19,7 +20,7 @@ function stripInjectedKnowledgeContext(text: string): string {
|
||||
}
|
||||
|
||||
function cleanUserText(text: string): string {
|
||||
return stripInjectedKnowledgeContext(text)
|
||||
return stripBusinessResponseGuidance(stripInjectedKnowledgeContext(text))
|
||||
// Remove [media attached: path (mime) | path] references
|
||||
.replace(/\s*\[media attached:[^\]]*\]/g, '')
|
||||
// Remove [message_id: uuid]
|
||||
@@ -156,7 +157,7 @@ export function extractText(message: RawMessage | unknown): string {
|
||||
result = cleanUserText(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
return stripBusinessResponseGuidance(result);
|
||||
}
|
||||
|
||||
export function extractTextSegments(message: RawMessage | unknown): string[] {
|
||||
@@ -183,7 +184,7 @@ export function extractTextSegments(message: RawMessage | unknown): string[] {
|
||||
segments = cleaned ? [cleaned] : [];
|
||||
}
|
||||
|
||||
if (!isUser) return segments;
|
||||
if (!isUser) return segments.map((segment) => stripBusinessResponseGuidance(segment)).filter((segment) => segment.length > 0);
|
||||
|
||||
return segments
|
||||
.map((segment) => cleanUserText(segment))
|
||||
|
||||
@@ -291,6 +291,82 @@ function formatDateTime(value: number | string | undefined, language: string): s
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
type TaskReminderTone = 'danger' | 'warning' | 'success' | 'info' | 'muted';
|
||||
|
||||
function getTaskReminder(
|
||||
job: CronJob,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
language: string,
|
||||
): { tone: TaskReminderTone; badge: string; text: string } {
|
||||
if (!job.enabled) {
|
||||
return {
|
||||
tone: 'muted',
|
||||
badge: t('scheduled.reminder.badges.paused'),
|
||||
text: t('scheduled.reminder.paused'),
|
||||
};
|
||||
}
|
||||
|
||||
if (job.lastRun && !job.lastRun.success) {
|
||||
return {
|
||||
tone: 'danger',
|
||||
badge: t('scheduled.reminder.badges.needsAction'),
|
||||
text: t('scheduled.reminder.failed', {
|
||||
error: job.lastRun.error || t('scheduled.reminder.unknownError'),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (job.lastRun?.warning || job.lastRun?.reconciled) {
|
||||
return {
|
||||
tone: 'warning',
|
||||
badge: t('scheduled.reminder.badges.review'),
|
||||
text: t('scheduled.reminder.warning'),
|
||||
};
|
||||
}
|
||||
|
||||
if (job.delivery?.mode === 'announce' && job.delivery.channel) {
|
||||
return {
|
||||
tone: 'info',
|
||||
badge: t('scheduled.reminder.badges.willNotify'),
|
||||
text: t('scheduled.reminder.delivery', {
|
||||
channel: getChannelDisplayName(job.delivery.channel),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (job.nextRun) {
|
||||
return {
|
||||
tone: 'success',
|
||||
badge: t('scheduled.reminder.badges.scheduled'),
|
||||
text: t('scheduled.reminder.nextRun', {
|
||||
time: formatDateTime(job.nextRun, language),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tone: 'info',
|
||||
badge: t('scheduled.reminder.badges.desktopOnly'),
|
||||
text: t('scheduled.reminder.desktopOnly'),
|
||||
};
|
||||
}
|
||||
|
||||
function getTaskReminderClasses(tone: TaskReminderTone): string {
|
||||
if (tone === 'danger') return 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200';
|
||||
if (tone === 'warning') return 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200';
|
||||
if (tone === 'success') return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-200';
|
||||
if (tone === 'info') return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-200';
|
||||
return 'border-slate-200 bg-slate-50 text-slate-600 dark:border-white/10 dark:bg-white/5 dark:text-slate-300';
|
||||
}
|
||||
|
||||
function getTaskReminderBadgeVariant(tone: TaskReminderTone): 'success' | 'warning' | 'destructive' | 'secondary' | 'outline' {
|
||||
if (tone === 'danger') return 'destructive';
|
||||
if (tone === 'warning') return 'warning';
|
||||
if (tone === 'success') return 'success';
|
||||
if (tone === 'muted') return 'secondary';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
children,
|
||||
@@ -970,6 +1046,7 @@ export function Tasks() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tabParam = searchParams.get('tab');
|
||||
const templateParam = searchParams.get('template');
|
||||
const highlightedTaskId = searchParams.get('task');
|
||||
const activeTab: TaskCenterTab = tabParam === 'history' ? 'history' : 'scheduled';
|
||||
const setActiveTab = (tab: TaskCenterTab) => setSearchParams(tab === 'scheduled' ? {} : { tab });
|
||||
|
||||
@@ -991,6 +1068,7 @@ export function Tasks() {
|
||||
const [draftScheduledTask, setDraftScheduledTask] = useState<ScheduledTaskDraft | undefined>();
|
||||
const [jobToDelete, setJobToDelete] = useState<CronJob | null>(null);
|
||||
const [configuredChannels, setConfiguredChannels] = useState<DeliveryChannelGroup[]>([]);
|
||||
const taskCardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const fetchConfiguredChannels = useCallback(async () => {
|
||||
try {
|
||||
@@ -1051,6 +1129,14 @@ export function Tasks() {
|
||||
const activeJobs = safeJobs.filter((job) => job.enabled);
|
||||
const failedJobs = safeJobs.filter((job) => job.lastRun && !job.lastRun.success);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'scheduled' || !highlightedTaskId) return;
|
||||
const target = taskCardRefs.current[highlightedTaskId];
|
||||
if (!target) return;
|
||||
if (typeof target.scrollIntoView !== 'function') return;
|
||||
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}, [activeTab, highlightedTaskId, safeJobs.length]);
|
||||
|
||||
const combinedRunRecords = useMemo(() => {
|
||||
const derived: TaskRunRecord[] = safeJobs.flatMap((job) => {
|
||||
if (!job.lastRun) return [];
|
||||
@@ -1287,10 +1373,18 @@ export function Tasks() {
|
||||
const account = channelGroup?.accounts.find((item) => item.accountId === job.delivery?.accountId);
|
||||
const deliveryAccountName = account ? getDeliveryAccountDisplayName(account, tCron) : job.delivery?.to;
|
||||
const resultText = job.lastRun ? (job.lastRun.success ? t('scheduled.card.success') : t('scheduled.card.failed')) : '-';
|
||||
const reminder = getTaskReminder(job, t, i18n.language);
|
||||
return (
|
||||
<div
|
||||
key={job.id}
|
||||
className="grid gap-3 border-b border-slate-200/70 px-3 py-3 transition-colors last:border-b-0 hover:bg-slate-50/80 dark:border-white/10 dark:hover:bg-white/5 lg:grid-cols-[minmax(220px,1.7fr)_minmax(120px,.6fr)_minmax(210px,1.1fr)_minmax(150px,.8fr)_minmax(180px,auto)] lg:items-center"
|
||||
ref={(element) => {
|
||||
taskCardRefs.current[job.id] = element;
|
||||
}}
|
||||
data-testid={`tasks-job-${job.id}`}
|
||||
className={cn(
|
||||
'grid gap-3 border-b border-slate-200/70 px-3 py-3 transition-colors last:border-b-0 hover:bg-slate-50/80 dark:border-white/10 dark:hover:bg-white/5 lg:grid-cols-[minmax(220px,1.7fr)_minmax(120px,.6fr)_minmax(210px,1.1fr)_minmax(150px,.8fr)_minmax(180px,auto)] lg:items-center',
|
||||
highlightedTaskId === job.id && 'bg-sky-50/80 ring-2 ring-inset ring-sky-300 dark:bg-sky-500/10 dark:ring-sky-400/40',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-start justify-between gap-3 lg:block">
|
||||
@@ -1300,6 +1394,18 @@ export function Tasks() {
|
||||
{pinned && <Badge variant="outline" className="rounded-md">{t('scheduled.pinned')}</Badge>}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">{job.message}</p>
|
||||
<div
|
||||
data-testid={`tasks-reminder-${job.id}`}
|
||||
className={cn(
|
||||
'mt-2 flex max-w-full items-center gap-2 rounded-md border px-2 py-1.5 text-xs',
|
||||
getTaskReminderClasses(reminder.tone),
|
||||
)}
|
||||
>
|
||||
<Badge variant={getTaskReminderBadgeVariant(reminder.tone)} className="shrink-0 rounded-md px-1.5 py-0 text-[11px]">
|
||||
{reminder.badge}
|
||||
</Badge>
|
||||
<span className="min-w-0 truncate">{reminder.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:hidden">
|
||||
<Switch checked={job.enabled} onCheckedChange={(enabled) => void toggleJob(job.id, enabled)} />
|
||||
|
||||
Reference in New Issue
Block a user