From da856f2b58e4ccb529d57667c1ca2971f875a6d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Thu, 2 Jul 2026 17:43:23 +0800 Subject: [PATCH] fix: improve ai writing chat streaming and intent handling --- .../src/open-canvas/api/ai-writing-intent.ts | 243 ++++++- .../ai-writing-chat/ChatFlowTimeline.tsx | 399 +++++++++++- .../ai-writing-chat/WritingChatPanel.tsx | 591 +++++++++++++++--- .../backend/app/gateway/routers/ai_writing.py | 372 ++++++++++- .../agents/ai_writing/intent_router.py | 186 +++++- 5 files changed, 1633 insertions(+), 158 deletions(-) diff --git a/frontend-web/src/open-canvas/api/ai-writing-intent.ts b/frontend-web/src/open-canvas/api/ai-writing-intent.ts index 81ca39b8..4342ef28 100644 --- a/frontend-web/src/open-canvas/api/ai-writing-intent.ts +++ b/frontend-web/src/open-canvas/api/ai-writing-intent.ts @@ -4,8 +4,9 @@ * 两级解析: * 1. `fastPathIntent`:高频确定性短句的前端规则快路径(确认/继续/重新搜索/重写大纲…), * 零延迟、零误判;**高代价动作(定稿/强制定稿/覆盖式重搜的模糊说法)绝不走快路径**。 - * 2. `parseWritingIntent`:POST /api/ai-writing/sessions/{id}/intent —— 后端读该会话的 - * graph checkpoint(素材/大纲/草稿/审核摘要)+ LLM 解析,返回结构化意图契约。 + * 2. `streamWritingIntent`:POST /api/ai-writing/sessions/{id}/intent/stream —— 后端读该会话的 + * graph checkpoint(素材/大纲/草稿/审核摘要)+ LLM 流式解析,实时返回 thinking/text, + * 最后一帧返回结构化意图契约。`parseWritingIntent` 保留为一次性兼容接口。 * * 返回的 payload 字段与 `UserInterventionPayload` 对齐,可直接喂 `submitIntervention`。 */ @@ -48,11 +49,19 @@ const TO_EDITOR_RE = /^((交给|让)?编辑(继续)?审核|送审|提交(编辑) const ACCEPT_REVIEW_RE = /^(按编辑(的)?意见(修改|改)|采纳编辑意见)$/; const SECTION_ALL_SUPPLEMENT_RE = /^(都|全部|全都)(补充|补)检索(素材)?$/; const SECTION_ALL_LOOSE_RE = /^(都|全部|全都)(按通用知识|用通用知识)?(续写|写|继续写)$/; +const WRITING_ACTION_VERB_RE = /(新增|增加|添加|加入|加上|补充|补上|补个|补一个|生成|撰写|写一|写个|写成|扩写|缩写|精简|改写|润色|调整|修改|重写|删除|删掉|拆成|合并|挪到|移动|规划|重新规划|设计|安排)/; +const WRITING_ACTION_TARGET_RE = /(第[一二三四五六七八九十\d]+[章节段]|章节|章|小节|段落|段|结尾|开头|标题|大纲|内容|案例|表格|总结章|总结章节|结论|摘要|观点|部分)/; +const INFO_QUESTION_RE = /(哪些|什么|为什么|是什么|有哪些|多少|是否)/; function stripPunct(text: string): string { return text.trim().replace(/[。..!!~~\s]+$/g, ''); } +function looksLikeWritingChangeRequest(text: string): boolean { + if (INFO_QUESTION_RE.test(text)) return false; + return WRITING_ACTION_VERB_RE.test(text) && WRITING_ACTION_TARGET_RE.test(text); +} + function ruleResult( intent: string, pausePoint: string | null, @@ -88,7 +97,7 @@ export function fastPathIntent( }, ): WritingIntentResult | null { const text = stripPunct(rawText); - if (!text || text.length > 12) return null; + if (!text) return null; // 「暂停中 + 继续」= 恢复运行(控制类,优先于阶段确认语义) if (ctx.isPaused && CONFIRM_RE.test(text)) { @@ -98,6 +107,29 @@ export function fastPathIntent( const pp = ctx.pausePoint; if (!pp) return null; + if (pp === 'outline_confirm' && looksLikeWritingChangeRequest(text)) { + return ruleResult('revise_outline', pp, 're_outline', { + action: 're_outline', + outlineFeedback: rawText.trim(), + }); + } + + if (pp === 'draft_confirm' && looksLikeWritingChangeRequest(text)) { + return ruleResult('revise_draft', pp, 'user_revise', { + action: 'user_revise', + userRevisionNotes: rawText.trim(), + }); + } + + if (pp === 'review_confirm' && looksLikeWritingChangeRequest(text)) { + return ruleResult('accept_review_with_user_notes', pp, 'accept_review', { + action: 'accept_review', + userRevisionNotes: rawText.trim(), + }); + } + + if (text.length > 12) return null; + if (pp === 'material_confirm') { if (CONFIRM_RE.test(text)) { // 不带 approvedMaterialIds → 后端默认采用全部素材 @@ -169,6 +201,25 @@ interface RawIntentResponse { clarify?: string | null; } +export type WritingIntentStreamEvent = + | { type: 'thinking'; chunk: string } + | { type: 'text'; chunk: string } + | { type: 'result'; result: WritingIntentResult } + | { type: 'done' } + | { type: 'error'; message: string }; + +export type WritingAnswerStreamEvent = + | { type: 'start' } + | { type: 'thinking'; chunk: string } + | { type: 'text'; chunk: string } + | { type: 'done' } + | { type: 'error'; message: string }; + +export interface WritingSuggestionMessage { + role: 'user' | 'assistant'; + content: string; +} + function normalizePayload(raw: Record | null | undefined, action: string | null): UserInterventionPayload | null { if (!action) return null; const p = raw ?? {}; @@ -196,6 +247,54 @@ function normalizePayload(raw: Record | null | undefined, actio return payload; } +function normalizeIntentResponse(raw: RawIntentResponse): WritingIntentResult { + const action = raw.action || null; + return { + intent: raw.intent || 'unknown', + pausePoint: raw.pause_point || null, + action, + payload: normalizePayload(raw.payload, action), + needResearch: Boolean(raw.need_research), + researchQuery: (raw.research_query || '').trim(), + answer: (raw.answer || '').trim(), + confidence: typeof raw.confidence === 'number' ? Math.max(0, Math.min(1, raw.confidence)) : 0, + clarify: (raw.clarify || '').trim(), + source: 'llm', + }; +} + +async function* parseSse(body: ReadableStream): AsyncGenerator<{ event: string; data: string }> { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let currentEvent = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + const trimmed = line.trimEnd(); + if (trimmed.startsWith('event: ')) { + currentEvent = trimmed.slice(7); + } else if (trimmed.startsWith('data: ')) { + yield { event: currentEvent, data: trimmed.slice(6) }; + currentEvent = ''; + } else if (trimmed === '') { + currentEvent = ''; + } + } + } + } finally { + reader.releaseLock(); + } +} + /** 后端 LLM 意图解析。会话必须已存在(sessionId = graph thread_id)。 */ export async function parseWritingIntent( sessionId: string, @@ -218,17 +317,129 @@ export async function parseWritingIntent( throw new Error(`意图解析失败 ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`); } const raw = (await resp.json()) as RawIntentResponse; - const action = raw.action || null; - return { - intent: raw.intent || 'unknown', - pausePoint: raw.pause_point || null, - action, - payload: normalizePayload(raw.payload, action), - needResearch: Boolean(raw.need_research), - researchQuery: (raw.research_query || '').trim(), - answer: (raw.answer || '').trim(), - confidence: typeof raw.confidence === 'number' ? Math.max(0, Math.min(1, raw.confidence)) : 0, - clarify: (raw.clarify || '').trim(), - source: 'llm', - }; + return normalizeIntentResponse(raw); +} + +/** 后端 LLM 意图解析的 SSE 流式版本,包含 ``/reasoning 思考增量。 */ +export async function* streamWritingIntent( + sessionId: string, + body: { text: string; status?: string; pausePoint?: string | null }, + signal?: AbortSignal, +): AsyncGenerator { + const resp = await apiFetch( + `${getBackendBaseURL()}/api/ai-writing/sessions/${encodeURIComponent(sessionId)}/intent/stream`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: body.text, + status: body.status ?? null, + pause_point: body.pausePoint ?? null, + }), + signal, + }, + ); + if (!resp.ok) { + const detail = await resp.text().catch(() => ''); + throw new Error(`意图解析失败 ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`); + } + if (!resp.body) { + throw new Error('意图解析失败:响应体为空'); + } + + for await (const { event, data } of parseSse(resp.body)) { + if (signal?.aborted) return; + try { + const parsed = JSON.parse(data) as Record; + if (event === 'thinking') { + yield { type: 'thinking', chunk: String(parsed.chunk ?? '') }; + } else if (event === 'text') { + yield { type: 'text', chunk: String(parsed.chunk ?? '') }; + } else if (event === 'result') { + yield { type: 'result', result: normalizeIntentResponse(parsed as RawIntentResponse) }; + } else if (event === 'done') { + yield { type: 'done' }; + return; + } else if (event === 'error') { + yield { type: 'error', message: String(parsed.message ?? '意图解析流式接口异常') }; + return; + } + } catch { + // 忽略畸形帧,等待下一帧或 done。 + } + } +} + +export async function fetchWritingAnswerSuggestions( + sessionId: string, + body: { messages: WritingSuggestionMessage[]; n?: number; modelName?: string | null }, +): Promise { + const resp = await apiFetch( + `${getBackendBaseURL()}/api/ai-writing/sessions/${encodeURIComponent(sessionId)}/suggestions`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: body.messages, + n: body.n ?? 3, + model_name: body.modelName || null, + }), + }, + ); + if (!resp.ok) return []; + const raw = (await resp.json().catch(() => ({}))) as { suggestions?: unknown[] }; + return (raw.suggestions ?? []) + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter(Boolean) + .slice(0, body.n ?? 3); +} + +export async function* streamWritingAnswer( + sessionId: string, + body: { text: string; status?: string; pausePoint?: string | null; modelName?: string | null }, + signal?: AbortSignal, +): AsyncGenerator { + const resp = await apiFetch( + `${getBackendBaseURL()}/api/ai-writing/sessions/${encodeURIComponent(sessionId)}/answer/stream`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: body.text, + status: body.status ?? null, + pause_point: body.pausePoint ?? null, + model_name: body.modelName || null, + }), + signal, + }, + ); + if (!resp.ok) { + const detail = await resp.text().catch(() => ''); + throw new Error(`回答生成失败 ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`); + } + if (!resp.body) { + throw new Error('回答生成失败:响应体为空'); + } + + for await (const { event, data } of parseSse(resp.body)) { + if (signal?.aborted) return; + try { + const parsed = JSON.parse(data) as Record; + if (event === 'start') { + yield { type: 'start' }; + } else if (event === 'thinking') { + yield { type: 'thinking', chunk: String(parsed.chunk ?? '') }; + } else if (event === 'text') { + yield { type: 'text', chunk: String(parsed.chunk ?? '') }; + } else if (event === 'done') { + yield { type: 'done' }; + return; + } else if (event === 'error') { + yield { type: 'error', message: String(parsed.message ?? '回答生成失败') }; + return; + } + } catch { + // 忽略畸形帧,等待下一帧或 done。 + } + } } diff --git a/frontend-web/src/open-canvas/components/ai-writing-chat/ChatFlowTimeline.tsx b/frontend-web/src/open-canvas/components/ai-writing-chat/ChatFlowTimeline.tsx index 9bd14ffc..0da950fa 100644 --- a/frontend-web/src/open-canvas/components/ai-writing-chat/ChatFlowTimeline.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing-chat/ChatFlowTimeline.tsx @@ -7,29 +7,58 @@ * 时间流 —— 用户发消息后,由它触发的检索/操作事件出现在消息**下方**(而不是 * 旧版「时间线在上、对话在下」两块分离导致的顺序错乱)。 * - * 时序对齐方式:每条对话消息记录创建时的 `progressEvents.length` 作为锚点 - * (anchor),渲染时消息插在「前 anchor 条事件之后、第 anchor+1 条事件之前」。 + * 时序对齐方式:每条对话消息记录创建时的信息流长度作为锚点(流程事件 + + * 已经出现的稳定尾部槽位),渲染时消息插在对应位置之后。 */ -import type { ReactNode } from 'react'; -import { BadgeCheck, Bot, CircleHelp, Info } from 'lucide-react'; +import { useMemo, useState, type ReactNode } from 'react'; +import { BadgeCheck, Bot, Check, CircleHelp, Info, Send, Sparkles, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; import { cn } from '@/lib/utils'; +import { resolveAllowMultiple } from '@/roundtable-planning/lib/clarification'; import type { CompletedIntervention, ProgressEvent } from '../../types/ai-writing'; import { TimelineStep } from '../ai-writing/TimelineStep'; import { CompletedInterventionCard } from '../ai-writing/CompletedInterventionCard'; import { UserRequirementCard } from '../ai-writing/UserRequirementCard'; -export type ChatKind = 'text' | 'clarify' | 'answer' | 'ack' | 'notice'; +export type ChatKind = 'text' | 'clarify' | 'answer' | 'ack' | 'notice' | 'assist'; + +export interface ChatClarificationOption { + id: string; + label: string; + description?: string; +} + +export interface ChatClarificationPayload { + question?: string; + context?: string | null; + clarification_type?: string; + options?: ChatClarificationOption[]; + allow_custom?: boolean; + allow_multiple?: boolean; +} export interface ChatFeedMessage { id: string; role: 'user' | 'assistant'; kind: ChatKind; text: string; - /** 创建该消息时 progressEvents 的长度:消息渲染在前 anchor 条事件之后。 */ + clarification?: ChatClarificationPayload; + answered?: boolean; + suggestions?: string[]; + suggestionsLoading?: boolean; + suggestionsHidden?: boolean; + streaming?: boolean; + /** 创建该消息时的信息流长度:消息渲染在前 anchor 个流程单元之后。 */ anchor: number; } +interface ClarificationSelection { + selectedIds: string[]; + custom: string; +} + const DISPLAYED_TYPES = new Set([ 'step_start', 'step_done', @@ -48,6 +77,7 @@ const DISPLAYED_TYPES = new Set([ type FeedRow = | { key: string; type: 'event'; event: ProgressEvent; slot: ReactNode; extendLineThroughSlot: boolean } + | { key: string; type: 'slot'; slot: ReactNode } | { key: string; type: 'chat'; msg: ChatFeedMessage }; interface ChatFlowTimelineProps { @@ -55,10 +85,17 @@ interface ChatFlowTimelineProps { completedInterventions: CompletedIntervention[]; currentPause: ProgressEvent | null; currentInterventionSlot?: ReactNode; + /** 渲染在流程事件之后的稳定尾部槽位;后续消息会锚到它下面。 */ + revisionSummarySlot?: ReactNode; chatMessages: ChatFeedMessage[]; + /** 由统一输入框触发的完成卡锚点:完成卡跟在对应用户/助手气泡后面。 */ + completedInterventionAnchors?: Map; /** 由底部输入框发起的干预序号集合:这些干预的「我的要求」卡不再重复渲染 * (用户气泡本身已按时间顺序展示了原话)。 */ suppressedRequirementIndices?: Set; + onClarificationSubmit?: (messageId: string, answer: string) => void; + onSuggestionClick?: (suggestion: string) => void; + onSuggestionsDismiss?: (messageId: string) => void; } export function ChatFlowTimeline({ @@ -66,19 +103,40 @@ export function ChatFlowTimeline({ completedInterventions, currentPause, currentInterventionSlot, + revisionSummarySlot, chatMessages, + completedInterventionAnchors, suppressedRequirementIndices, + onClarificationSubmit, + onSuggestionClick, + onSuggestionsDismiss, }: ChatFlowTimelineProps) { if (events.length === 0 && chatMessages.length === 0) return null; - // 消息按锚点分桶(锚点超出当前事件数的挂在末尾) + const tailSlotCount = revisionSummarySlot ? 1 : 0; + const maxAnchor = events.length + tailSlotCount; + + // 消息按锚点分桶(锚点超出当前信息流长度的挂在末尾) const msgsByAnchor = new Map(); for (const msg of chatMessages) { - const anchor = Math.max(0, Math.min(msg.anchor, events.length)); + const anchor = Math.max(0, Math.min(msg.anchor, maxAnchor)); const bucket = msgsByAnchor.get(anchor); if (bucket) bucket.push(msg); else msgsByAnchor.set(anchor, [msg]); } + const completionRowsByAnchor = new Map(); + completedInterventionAnchors?.forEach((rawAnchor, interventionIdx) => { + const record = completedInterventions[interventionIdx]; + if (!record) return; + const anchor = Math.max(0, Math.min(rawAnchor, maxAnchor)); + const rows = completionRowsByAnchor.get(anchor) ?? []; + rows.push({ + key: `composer-completed-${interventionIdx}`, + type: 'slot', + slot: , + }); + completionRowsByAnchor.set(anchor, rows); + }); // 最后一个 await_user 事件的全量下标:只有它才可能挂当前干预卡 let lastAwaitUserIdx = -1; @@ -87,15 +145,18 @@ export function ChatFlowTimeline({ }); const rows: FeedRow[] = []; - const pushChats = (anchor: number) => { + const pushAnchoredRows = (anchor: number) => { for (const msg of msgsByAnchor.get(anchor) ?? []) { rows.push({ key: msg.id, type: 'chat', msg }); } + for (const completionRow of completionRowsByAnchor.get(anchor) ?? []) { + rows.push(completionRow); + } }; let awaitUserIdx = 0; events.forEach((event, fullIdx) => { - pushChats(fullIdx); + pushAnchoredRows(fullIdx); if (!DISPLAYED_TYPES.has(event.type)) return; let slot: ReactNode = null; @@ -107,13 +168,16 @@ export function ChatFlowTimeline({ awaitUserIdx += 1; if (completedRecord) { - const showRequirement = !suppressedRequirementIndices?.has(interventionIdx); - slot = ( -
- {showRequirement && } - -
- ); + const movedToComposerAnchor = completedInterventionAnchors?.has(interventionIdx); + if (!movedToComposerAnchor) { + const showRequirement = !suppressedRequirementIndices?.has(interventionIdx); + slot = ( +
+ {showRequirement && } + +
+ ); + } } else if (currentPause && fullIdx === lastAwaitUserIdx) { slot = currentInterventionSlot; extendLineThroughSlot = false; @@ -122,14 +186,32 @@ export function ChatFlowTimeline({ rows.push({ key: `event-${fullIdx}`, type: 'event', event, slot, extendLineThroughSlot }); }); - pushChats(events.length); + if (revisionSummarySlot) { + pushAnchoredRows(events.length); + rows.push({ key: 'revision-summary', type: 'slot', slot: revisionSummarySlot }); + pushAnchoredRows(events.length + 1); + } else { + pushAnchoredRows(events.length); + } return (
{rows.map((row, idx) => { const isLast = idx === rows.length - 1; if (row.type === 'chat') { - return ; + return ( + + ); + } + if (row.type === 'slot') { + return {row.slot}; } return ( +
+ {!isLast &&
} +
+
+ {children} +
+
+ ); +} + /** 信息流里的对话气泡行:左侧延续时间线竖线,与流程事件保持同一视觉脊柱。 */ -function ChatFeedRow({ msg, isLast }: { msg: ChatFeedMessage; isLast: boolean }) { +function ChatFeedRow({ + msg, + isLast, + onClarificationSubmit, + onSuggestionClick, + onSuggestionsDismiss, +}: { + msg: ChatFeedMessage; + isLast: boolean; + onClarificationSubmit?: (messageId: string, answer: string) => void; + onSuggestionClick?: (suggestion: string) => void; + onSuggestionsDismiss?: (messageId: string) => void; +}) { return (
{/* 竖线占位列(与 TimelineStep 的 size-4 图标列同宽),保持时间线连续 */} @@ -154,13 +261,28 @@ function ChatFeedRow({ msg, isLast }: { msg: ChatFeedMessage; isLast: boolean }) {!isLast &&
}
- +
); } -export function ChatBubble({ msg }: { msg: Pick }) { +export function ChatBubble({ + msg, + onClarificationSubmit, + onSuggestionClick, + onSuggestionsDismiss, +}: { + msg: ChatFeedMessage; + onClarificationSubmit?: (messageId: string, answer: string) => void; + onSuggestionClick?: (suggestion: string) => void; + onSuggestionsDismiss?: (messageId: string) => void; +}) { if (msg.role === 'user') { return (
@@ -175,6 +297,7 @@ export function ChatBubble({ msg }: { msg: Pick, answer: , clarify: , + assist: , ack: , notice: , }; @@ -184,17 +307,227 @@ export function ChatBubble({ msg }: { msg: Pick {iconByKind[msg.kind]}
-
- {msg.text} -
+ {msg.kind === 'assist' && msg.clarification ? ( + + ) : msg.kind === 'answer' ? ( +
+
+ {msg.text || (msg.streaming ? '正在生成回答…' : '')} + {msg.streaming && ( + + )} +
+ +
+ ) : ( +
+ {msg.text} +
+ )} +
+ ); +} + +function AnswerSuggestionChips({ + msg, + onSuggestionClick, + onDismiss, +}: { + msg: ChatFeedMessage; + onSuggestionClick?: (suggestion: string) => void; + onDismiss?: (messageId: string) => void; +}) { + const suggestions = (msg.suggestions ?? []).filter((item) => item.trim()); + if (msg.suggestionsHidden || (!msg.suggestionsLoading && suggestions.length === 0)) { + return null; + } + + return ( +
+
+ + 继续问 + +
+ {msg.suggestionsLoading ? ( +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ ) : ( +
+ {suggestions.map((suggestion) => ( + + ))} +
+ )} +
+ ); +} + +function ChatClarificationCard({ + msg, + onSubmit, +}: { + msg: ChatFeedMessage; + onSubmit?: (messageId: string, answer: string) => void; +}) { + const payload = msg.clarification; + const [selection, setSelection] = useState({ selectedIds: [], custom: '' }); + + const options = useMemo( + () => + (payload?.options ?? []).filter( + (option) => typeof option.label === 'string' && option.label.trim(), + ), + [payload?.options], + ); + + if (!payload) return null; + + const allowMultiple = resolveAllowMultiple(payload.allow_multiple, payload.clarification_type); + const disabled = Boolean(msg.answered); + const canUseCustom = payload.allow_custom !== false; + + const buildAnswer = (next: ClarificationSelection = selection) => { + const selected = options + .filter((option) => next.selectedIds.includes(option.id || option.label)) + .map((option) => option.label.trim()) + .filter(Boolean); + const custom = next.custom.trim(); + return [...selected, custom].filter(Boolean).join('\n'); + }; + + const canSubmit = Boolean(buildAnswer()); + + const handleOptionClick = (optionId: string) => { + if (disabled) return; + const nextIds = allowMultiple + ? selection.selectedIds.includes(optionId) + ? selection.selectedIds.filter((id) => id !== optionId) + : [...selection.selectedIds, optionId] + : [optionId]; + setSelection((prev) => ({ ...prev, selectedIds: nextIds })); + }; + + const handleSubmit = () => { + if (disabled) return; + const answer = buildAnswer(); + if (!answer) return; + onSubmit?.(msg.id, answer); + }; + + return ( +
+
+
+ 需要你的确认 + {disabled && ( + + 已回答 + + )} +
+ {payload.context ? ( +

{payload.context}

+ ) : null} +
+ {payload.question || msg.text} +
+
+ + {options.length > 0 ? ( +
+
+ {allowMultiple ? '可多选(再次点击取消选择)' : '请选择一项'} +
+ {options.map((option) => { + const optionId = option.id || option.label; + const selected = selection.selectedIds.includes(optionId); + return ( + + ); + })} +
+ ) : null} + + {canUseCustom ? ( +
+