Files
agent/apps/web_v1/src/pages/projects/project-inbox.tsx

248 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useRef, useState } from 'react'
import { Alert, Button, Card, Checkbox, Empty, Space, Tag, Typography } from '@arco-design/web-react'
import { IconCheckCircle, IconRobot } from '@arco-design/web-react/icon'
import { ApiError } from '../../api/client'
import type { InboxSuggestionDTO } from '../../api/inbox'
import type { InboxConfirmationOutcome, InboxItem, ProjectWorkspace } from './project-types'
const { Title, Text, Paragraph } = Typography
type ProjectInboxProps = {
activeWorkspace: ProjectWorkspace
onAnalyze: (inboxId: string) => Promise<InboxSuggestionDTO[]>
onConfirm: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
}
type UncertainConfirmation = {
inboxItemId: string
suggestionIds: string[]
}
export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectInboxProps) {
const [selectedItemId, setSelectedItemId] = useState(activeWorkspace.inbox[0]?.id ?? '')
const [draftSuggestions, setDraftSuggestions] = useState<InboxSuggestionDTO[]>([])
const [selectedSuggestionIds, setSelectedSuggestionIds] = useState<string[]>([])
const [analyzing, setAnalyzing] = useState(false)
const [confirming, setConfirming] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [refreshWarning, setRefreshWarning] = useState('')
const [locallyConfirmedItemIds, setLocallyConfirmedItemIds] = useState<string[]>([])
const [confirmationUncertain, setConfirmationUncertain] = useState<UncertainConfirmation | null>(null)
const analysisGeneration = useRef(0)
const selectedItem = useMemo(
() => activeWorkspace.inbox.find((item) => item.id === selectedItemId) ?? activeWorkspace.inbox[0],
[activeWorkspace.inbox, selectedItemId],
)
function selectItem(item: InboxItem) {
if (confirmationUncertain) return
analysisGeneration.current += 1
setSelectedItemId(item.id)
setDraftSuggestions([])
setSelectedSuggestionIds([])
setError('')
setSuccess('')
setRefreshWarning('')
setAnalyzing(false)
}
async function analyzeSelectedItem() {
if (!selectedItem || selectedItem.status !== 'open' || confirmationUncertain) return
setAnalyzing(true)
setError('')
setSuccess('')
setRefreshWarning('')
const generation = analysisGeneration.current + 1
analysisGeneration.current = generation
const analyzedItemId = selectedItem.id
try {
const suggestions = await onAnalyze(analyzedItemId)
if (analysisGeneration.current !== generation || selectedItemId !== analyzedItemId) return
setDraftSuggestions(suggestions)
setSelectedSuggestionIds(suggestions.map((suggestion) => suggestion.id))
} catch (reason) {
if (analysisGeneration.current !== generation) return
setError(reason instanceof Error ? reason.message : '分析失败,请稍后重试')
} finally {
if (analysisGeneration.current === generation) setAnalyzing(false)
}
}
async function confirmSelectedSuggestions() {
if (!selectedItem) return
const confirmation = confirmationUncertain ?? {
inboxItemId: selectedItem.id,
suggestionIds: [...selectedSuggestionIds],
}
if (confirmation.suggestionIds.length === 0) {
setError('请至少勾选一条建议')
return
}
setConfirming(true)
setError('')
setSuccess('')
try {
const outcome = await onConfirm(confirmation.inboxItemId, confirmation.suggestionIds)
setConfirmationUncertain(null)
setLocallyConfirmedItemIds((current) => current.includes(confirmation.inboxItemId) ? current : [...current, confirmation.inboxItemId])
setSuccess(`已创建 ${outcome.createdCount} 个对象`)
setRefreshWarning(outcome.refreshError ?? '')
} catch (reason) {
setConfirmationUncertain(isUncertainConfirmationError(reason) ? confirmation : null)
setError(confirmationErrorMessage(reason))
} finally {
setConfirming(false)
}
}
function toggleSuggestion(identity: string, checked: boolean) {
if (confirmationUncertain) return
setSelectedSuggestionIds((current) => checked
? current.includes(identity) ? current : [...current, identity]
: current.filter((value) => value !== identity))
}
return (
<div className="project-channel-page project-inbox-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}>Inbox </Title>
<Text type="secondary">稿</Text>
</div>
</div>
{activeWorkspace.inbox.length === 0 ? (
<Card className="queue-section" bordered>
<Empty description="当前没有待处理的 Inbox 内容" />
</Card>
) : (
<div className="mail-layout inbox-workspace">
<Card className="queue-section mail-list" bordered>
<div className="section-header">
<Title heading={6}>{activeWorkspace.inbox.length}</Title>
</div>
{activeWorkspace.inbox.map((item) => (
<button
key={item.id}
type="button"
className={selectedItem?.id === item.id ? 'mail-item active' : 'mail-item'}
disabled={confirming || confirmationUncertain !== null}
onClick={() => selectItem(item)}
>
<span>{item.title}</span>
<Text type="secondary" ellipsis={{ showTooltip: true }}>{item.summary || '暂无内容'}</Text>
<div>
<Tag color={isInboxItemProcessed(item, locallyConfirmedItemIds) ? 'green' : 'orange'}>{isInboxItemProcessed(item, locallyConfirmedItemIds) ? '已处理' : '待处理'}</Tag>
<time>{item.time}</time>
</div>
</button>
))}
</Card>
<Card className="queue-section mail-detail inbox-detail" bordered>
{selectedItem ? (
<>
<div className="inbox-detail-head">
<div>
<Space size={8}>
<Title heading={5}>{selectedItem.title}</Title>
<Tag color={isInboxItemProcessed(selectedItem, locallyConfirmedItemIds) ? 'green' : 'orange'}>
{isInboxItemProcessed(selectedItem, locallyConfirmedItemIds) ? '已处理' : '待处理'}
</Tag>
</Space>
<Text type="secondary">{selectedItem.meta} · {selectedItem.time}</Text>
</div>
<Button
icon={<IconRobot />}
loading={analyzing}
disabled={confirming || confirmationUncertain !== null || isInboxItemProcessed(selectedItem, locallyConfirmedItemIds)}
onClick={() => void analyzeSelectedItem()}
>
</Button>
</div>
<section className="inbox-content" aria-label="Inbox 原始内容">
<Text className="inbox-section-label"></Text>
<Paragraph>{selectedItem.summary || '暂无内容'}</Paragraph>
</section>
<section className="inbox-draft-section" aria-label="分析建议">
<div className="section-header">
<Title heading={6}></Title>
<Text type="secondary"></Text>
</div>
{draftSuggestions.length === 0 ? (
<Text type="secondary">稿</Text>
) : (
<div className="inbox-suggestion-list">
{draftSuggestions.map((suggestion) => (
<Checkbox
key={suggestion.id}
checked={selectedSuggestionIds.includes(suggestion.id)}
disabled={confirming || confirmationUncertain !== null || isInboxItemProcessed(selectedItem, locallyConfirmedItemIds)}
onChange={(checked) => toggleSuggestion(suggestion.id, checked)}
>
<span className="inbox-suggestion-copy">
<span>
<Tag color={suggestionColor(suggestion.kind)}>{suggestionLabel(suggestion.kind)}</Tag>
<strong>{suggestion.title}</strong>
</span>
<Text type="secondary">{suggestion.body || '暂无补充内容'}</Text>
</span>
</Checkbox>
))}
</div>
)}
</section>
{error && <Alert className="inbox-feedback" type="error" content={error} />}
{success && <Alert className="inbox-feedback" type="success" content={success} icon={<IconCheckCircle />} />}
{refreshWarning && <Alert className="inbox-feedback" type="warning" content={refreshWarning} />}
<div className="inbox-confirm-bar">
<Text type="secondary"></Text>
<Button
type="primary"
loading={confirming}
disabled={confirming || analyzing || isInboxItemProcessed(selectedItem, locallyConfirmedItemIds) || draftSuggestions.length === 0}
onClick={() => void confirmSelectedSuggestions()}
>
{confirmationUncertain ? '重试确认' : '确认创建'}
</Button>
</div>
</>
) : null}
</Card>
</div>
)}
</div>
)
}
function isUncertainConfirmationError(reason: unknown) {
return reason instanceof ApiError && (reason.code === 'network_error' || reason.code === 'invalid_response')
}
function confirmationErrorMessage(reason: unknown) {
if (isUncertainConfirmationError(reason)) {
return '确认结果未知,服务端支持幂等处理,可安全重试'
}
return reason instanceof Error ? reason.message : '确认创建失败,请稍后重试'
}
function isInboxItemProcessed(item: InboxItem, locallyConfirmedItemIds: string[]) {
return item.status !== 'open' || locallyConfirmedItemIds.includes(item.id)
}
function suggestionLabel(kind: InboxSuggestionDTO['kind']) {
if (kind === 'task') return '任务'
return 'Markdown 文档'
}
function suggestionColor(kind: InboxSuggestionDTO['kind']) {
if (kind === 'task') return 'arcoblue'
return 'green'
}