diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db419207b9..86d21bd0be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -391,6 +391,12 @@ importers: '@eslint/js': specifier: ^9.24.0 version: 9.39.4 + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/node': specifier: ^20.10.0 version: 20.19.37 @@ -1678,6 +1684,25 @@ packages: '@tauri-apps/plugin-opener@2.5.3': resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tiptap/core@3.20.4': resolution: {integrity: sha512-3i/DG89TFY/b34T5P+j35UcjYuB5d3+9K8u6qID+iUqNPiza015HPIZLuPfE5elNwVdV3EXIoPo0LLeBLgXXAg==} peerDependencies: @@ -1869,6 +1894,9 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2407,6 +2435,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -3055,6 +3086,9 @@ packages: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -4010,6 +4044,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -4501,6 +4539,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-format@30.3.0: resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -4662,6 +4704,9 @@ packages: typescript: optional: true + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -6756,6 +6801,27 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + '@tiptap/core@3.20.4(@tiptap/pm@3.20.4)': dependencies: '@tiptap/pm': 3.20.4 @@ -6965,6 +7031,8 @@ snapshots: '@tsconfig/node16@1.0.4': {} + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 @@ -7817,6 +7885,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} assertion-error@2.0.1: {} @@ -8489,6 +8561,8 @@ snapshots: diff@8.0.3: {} + dom-accessibility-api@0.5.16: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -9614,6 +9688,8 @@ snapshots: dependencies: react: 18.3.1 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -10376,6 +10452,12 @@ snapshots: prelude-ls@1.2.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-format@30.3.0: dependencies: '@jest/schemas': 30.0.5 @@ -10596,6 +10678,8 @@ snapshots: react-dom: 18.3.1(react@18.3.1) typescript: 5.8.3 + react-is@17.0.2: {} + react-is@18.3.1: {} react-markdown@10.1.0(@types/react@18.3.27)(react@18.3.1): diff --git a/src/web-ui/package.json b/src/web-ui/package.json index 84dfcb213d..a404c6f6af 100644 --- a/src/web-ui/package.json +++ b/src/web-ui/package.json @@ -44,13 +44,13 @@ "@xterm/xterm": "^6.1.0-beta.101", "diff": "^8.0.3", "highlight.js": "^11.11.1", - "modern-screenshot": "^4.7.0", "i18next": "^25.8.0", "immer": "^11.1.3", "jszip": "^3.10.1", "katex": "^0.16.25", "lucide-react": "^0.541.0", "mermaid": "^11.10.1", + "modern-screenshot": "^4.7.0", "monaco-editor": "^0.52.2", "morphdom": "^2.7.8", "partial-json": "^0.1.7", @@ -77,6 +77,8 @@ }, "devDependencies": { "@eslint/js": "^9.24.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/node": "^20.10.0", "@types/path-browserify": "^1.0.3", "@types/prismjs": "^1.26.5", diff --git a/src/web-ui/src/app/scenes/session/ChatPane.tsx b/src/web-ui/src/app/scenes/session/ChatPane.tsx index 7e92dd16ea..03ddde14d8 100644 --- a/src/web-ui/src/app/scenes/session/ChatPane.tsx +++ b/src/web-ui/src/app/scenes/session/ChatPane.tsx @@ -9,6 +9,8 @@ import React, { useCallback, memo, useEffect, useRef } from 'react'; import { ModernFlowChatContainer as FlowChatContainer } from '../../../flow_chat/components/modern/ModernFlowChatContainer'; import { ChatInput } from '../../../flow_chat/components/ChatInput'; import type { ChatInputRegistration } from '../../../flow_chat/components/chatInputRegistration'; +import { QuoteSelect } from '../../../flow_chat/components/QuoteSelect/QuoteSelect'; +import { QuoteDock } from '../../../flow_chat/components/QuoteSelect/QuoteDock'; import { useCanvasStore } from '../../components/panels/content-canvas/stores/canvasStore'; import type { LineRange } from '@/component-library'; import path from 'path-browserify'; @@ -178,8 +180,10 @@ const ChatPaneInner: React.FC = ({ isSceneActive={isSceneActive} onSendMessage={(_message: string) => {}} registration={chatInputRegistration} + composerDock={(draft) => } /> )} + ); }; diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 6813ade576..8de390db03 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -226,6 +226,11 @@ export interface ChatInputProps { * standard composer. The registration never replaces ChatInput's UI. */ registration?: ChatInputRegistration; + /** + * Host-injected area rendered above the composer (render prop so the host + * can subscribe to draft changes). Never replaces the composer itself. + */ + composerDock?: (draft: string) => React.ReactNode; } type SlashActionItem = { @@ -425,6 +430,7 @@ export const ChatInput: React.FC = ({ onSendMessage, isSceneActive = true, registration, + composerDock, }) => { const deviceSurfaceScope = getActiveSurfaceScope(); const { t } = useTranslation('flow-chat'); @@ -5541,6 +5547,11 @@ export const ChatInput: React.FC = ({
+ {composerDock != null && ( +
+ {composerDock(inputState.value)} +
+ )}
{showTargetSwitcher && (
diff --git a/src/web-ui/src/flow_chat/components/QuoteSelect/QuoteDock.tsx b/src/web-ui/src/flow_chat/components/QuoteSelect/QuoteDock.tsx new file mode 100644 index 0000000000..5cc13a44ba --- /dev/null +++ b/src/web-ui/src/flow_chat/components/QuoteSelect/QuoteDock.tsx @@ -0,0 +1,51 @@ +/** + * QuoteDock — 输入框上方的可移除引用卡片 + * + * 渲染进 ChatInput 新增的 composerDock 扩展点(见 chatinput-patch.md)。 + * 与草稿内容双向同步: + * - draft 中 marker 被删(发送清空 / 手动删除)→ 卡片自动摘除 + * - 点卡片 ✕ → 从草稿移除 marker 并压缩多余空行 + */ +import React, { useEffect } from 'react'; +import { useI18n } from '@/infrastructure/i18n'; +import { quoteSelectStore } from './quoteSelectStore'; + +export const QuoteDock: React.FC<{ draft: string }> = ({ draft }) => { + const { t } = useI18n('flow-chat'); + const quotes = quoteSelectStore((s) => s.quotes); + + useEffect(() => { + quoteSelectStore.getState().syncDraft(draft); + }, [draft]); + + if (quotes.length === 0) return null; + + return ( +
+ {quotes.map((q) => ( +
+ + + + {t('quote.from')} · {q.who} + {q.pos ? ` · ${t('quote.msgNo', { pos: q.pos })}` : ''} + + {q.text.replace(/\s+/g, ' ')} + + +
+ ))} +
+ ); +}; + +export default QuoteDock; diff --git a/src/web-ui/src/flow_chat/components/QuoteSelect/QuoteSelect.scss b/src/web-ui/src/flow_chat/components/QuoteSelect/QuoteSelect.scss new file mode 100644 index 0000000000..0f585176e2 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/QuoteSelect/QuoteSelect.scss @@ -0,0 +1,121 @@ +/** + * QuoteSelect 样式 + * + * 说明:颜色/圆角等 token 优先走 BitFun 的 appearance/design-token 体系; + * 下方变量名为占位(--bf-*),合入 PR 前按仓库现有 token 对齐。 + * (待确认项:见 README 风险项 R4) + */ + +.quote-select__menu { + position: fixed; + z-index: 999999; + display: flex; + gap: 4px; + padding: 5px; + background: var(--bf-surface-primary, #fff); + border: 1px solid var(--bf-border-l1, #e5e7eb); + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16); + font-size: 13px; + user-select: none; + -webkit-user-select: none; + white-space: nowrap; + + .quote-select__menu-btn { + display: flex; + align-items: center; + gap: 5px; + padding: 6px 10px; + border: none; + border-radius: 7px; + background: transparent; + color: var(--bf-label-primary, #111827); + cursor: pointer; + font-size: 13px; + line-height: 1; + font-family: inherit; + + &:hover { + background: var(--bf-interactive-bg-hover, rgba(0, 0, 0, 0.05)); + } + + &--copied { + color: var(--bf-accent, #4d6bfe); + } + } +} + +.quote-select__dock { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 2px 2px 6px; +} + +.quote-select__chip { + display: flex; + align-items: center; + gap: 8px; + max-width: 100%; + padding: 6px 8px 6px 10px; + background: var(--bf-interactive-bg-hover-solid, rgba(0, 0, 0, 0.04)); + border: 1px solid var(--bf-border-l1, #e5e7eb); + border-radius: 10px; + font-size: 12px; + + .quote-select__chip-ico { + width: 18px; + height: 18px; + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + border-radius: 5px; + background: var(--bf-accent, #4d6bfe); + color: #fff; + font-size: 11px; + font-weight: 700; + } + + .quote-select__chip-body { + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; + } + + .quote-select__chip-label { + color: var(--bf-label-secondary, #6b7280); + font-size: 11px; + line-height: 1.25; + } + + .quote-select__chip-text { + color: var(--bf-label-primary, #111827); + font-size: 12px; + line-height: 1.4; + max-width: 460px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .quote-select__chip-x { + flex: 0 0 auto; + width: 18px; + height: 18px; + border: none; + border-radius: 5px; + background: transparent; + color: var(--bf-label-tertiary, #9ca3af); + cursor: pointer; + font-size: 12px; + line-height: 1; + padding: 0; + + &:hover { + background: rgba(239, 68, 68, 0.14); + color: #ef4444; + } + } +} diff --git a/src/web-ui/src/flow_chat/components/QuoteSelect/QuoteSelect.test.tsx b/src/web-ui/src/flow_chat/components/QuoteSelect/QuoteSelect.test.tsx new file mode 100644 index 0000000000..07e54ed1c3 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/QuoteSelect/QuoteSelect.test.tsx @@ -0,0 +1,211 @@ +/** + * QuoteSelect 单元测试(vitest + jsdom,对齐仓库现有测试风格) + * + * 覆盖: + * - makeMarker:格式 / 截断 / 位置文案 + * - pickFromSelection:消息行内选中 / 消息外选中 / 折叠选区 / 空文本 + * - store.fillIntoComposer:事件负载 = marker + 当前草稿 + * - store.syncDraft:marker 消失 → 卡片摘除 + * - store.removeQuote:删卡 → 草稿移除 marker 并压缩空行 + */ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { quoteSelectStore, makeMarker, MAX_QUOTE_LEN } from './quoteSelectStore'; +import { pickFromSelection } from './quoteSelectPick'; +import { QuoteDock } from './QuoteDock'; + +// ---- globalEventBus mock(避免依赖真实事件总线) ---- +vi.mock('@/infrastructure/event-bus', () => ({ + globalEventBus: { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }, +})); + +import { globalEventBus } from '@/infrastructure/event-bus'; + +// ---- i18n mock ---- +vi.mock('@/infrastructure/i18n', () => ({ + useI18n: () => ({ t: (k: string, vars?: Record) => { + const table: Record = { + 'quote.copy': '复制文字', + 'quote.copied': '已复制', + 'quote.addToConversation': '添加到对话', + 'quote.sourceUser': '你的原话', + 'quote.sourceAssistant': 'AI 的回答', + 'quote.remove': '移除这条引用', + 'quote.from': '引用', + 'quote.msgNo': `第 ${String(vars?.pos ?? '')} 条消息`, + }; + return table[k] ?? k; + } }), +})); + +describe('makeMarker', () => { + it('生成带来源与位置的引用标记', () => { + expect(makeMarker('你的原话', 3, 'hello')).toBe( + '【引用自对话第 3 条消息(你的原话)】「hello」', + ); + }); + + it('无位置时标注对话历史', () => { + expect(makeMarker('AI 的回答', null, 'hi')).toBe( + '【引用自对话历史(AI 的回答)】「hi」', + ); + }); + + it('超过 2000 字截断并标注', () => { + const long = 'x'.repeat(MAX_QUOTE_LEN + 100); + const marker = makeMarker('你的原话', 1, long); + expect(marker).toContain('…(原文更长,已截断)'); + expect(marker.length).toBeLessThan(long.length + 100); + }); +}); + +describe('pickFromSelection', () => { + function makeMessageRow(component: string, turnId: string | null): HTMLElement { + const pane = document.createElement('div'); + pane.setAttribute('data-bf-component', 'chat-pane'); + const row = document.createElement('div'); + row.setAttribute('data-bf-component', component); + if (turnId) row.setAttribute('data-turn-id', turnId); + const content = document.createElement('div'); + content.setAttribute('data-bf-part', 'content'); + content.textContent = '选中文本'; + row.appendChild(content); + pane.appendChild(row); + document.body.appendChild(pane); + return row; + } + + function mockSelection(anchorNode: Node | null, text: string, rect: DOMRect) { + const sel = { + isCollapsed: !text, + rangeCount: text ? 1 : 0, + toString: () => text, + getRangeAt: () => ({ startContainer: anchorNode, getBoundingClientRect: () => rect }), + anchorNode, + }; + Object.defineProperty(window, 'getSelection', { value: () => sel, configurable: true }); + } + + afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + it('在用户消息行内选中 → who=user、turnId 正确、序号=1', () => { + const row = makeMessageRow('user-message-item', 'turn-42'); + mockSelection(row.querySelector('[data-bf-part="content"]'), '选中文本', new DOMRect(0, 0, 100, 20)); + const pick = pickFromSelection(); + expect(pick).not.toBeNull(); + expect(pick!.who).toBe('user'); + expect(pick!.turnId).toBe('turn-42'); + expect(pick!.pos).toBe(1); + }); + + it('在 AI 消息行内选中 → who=assistant', () => { + const row = makeMessageRow('model-round-item', null); + mockSelection(row, '回答文本', new DOMRect(0, 0, 100, 20)); + const pick = pickFromSelection(); + expect(pick!.who).toBe('assistant'); + expect(pick!.turnId).toBeNull(); + }); + + it('选区在消息行外 → null', () => { + const outside = document.createElement('div'); + document.body.appendChild(outside); + mockSelection(outside, '外部文本', new DOMRect(0, 0, 100, 20)); + expect(pickFromSelection()).toBeNull(); + }); + + it('折叠选区 / 空文本 → null', () => { + mockSelection(document.body, '', new DOMRect(0, 0, 0, 0)); + expect(pickFromSelection()).toBeNull(); + }); +}); + +describe('quoteSelectStore', () => { + beforeEach(() => { + quoteSelectStore.setState({ quotes: [], draft: '' }); + vi.mocked(globalEventBus.emit).mockClear(); + }); + + it('fillIntoComposer:marker 在上、草稿在下,走 replace 模式', () => { + // 模拟 chat-input:get-state 同步回填 + vi.mocked(globalEventBus.emit).mockImplementation((event: string, payload: any) => { + if (event === 'chat-input:get-state' && payload?.getValue) { + payload.getValue('已有草稿'); + } + }); + quoteSelectStore.getState().fillIntoComposer('【引用】「hi」'); + const emit = vi.mocked(globalEventBus.emit); + const fillCall = emit.mock.calls.find(([e]) => e === 'fill-chat-input'); + expect(fillCall).toBeDefined(); + expect(fillCall![1]).toEqual({ + content: '【引用】「hi」\n\n已有草稿', + mode: 'replace', + }); + }); + + it('syncDraft:marker 从草稿消失 → 卡片摘除', () => { + const s = quoteSelectStore.getState(); + s.addQuote({ marker: '【引用】「hi」', text: 'hi', who: '你的原话', pos: 1 }); + expect(quoteSelectStore.getState().quotes).toHaveLength(1); + quoteSelectStore.getState().syncDraft('完全不包含 marker 的内容'); + expect(quoteSelectStore.getState().quotes).toHaveLength(0); + }); + + it('removeQuote:从草稿移除 marker 并压缩多余空行', () => { + const s = quoteSelectStore.getState(); + s.addQuote({ marker: 'M1', text: 'hi', who: '你的原话', pos: 1 }); + const id = quoteSelectStore.getState().quotes[0].id; + quoteSelectStore.setState({ draft: 'M1\n\n\n\n后续' }); + quoteSelectStore.getState().removeQuote(id); + const emit = vi.mocked(globalEventBus.emit); + const fillCall = emit.mock.calls.find(([e]) => e === 'fill-chat-input'); + // removeQuote 保留 marker 移除后的前导空行(对齐 dsh:仅压缩 3+ 连续空行) + expect(fillCall![1].content).toBe('\n\n后续'); + }); +}); + +describe('QuoteDock', () => { + beforeEach(() => { + quoteSelectStore.setState({ quotes: [], draft: '' }); + }); + + afterEach(() => { + cleanup(); + }); + + it('有引用时渲染卡片,无引用时返回 null', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + cleanup(); // 先卸载,避免 store 更新触发旧实例重渲染导致重复节点 + + quoteSelectStore.getState().addQuote({ + marker: 'M1', + text: '原文内容', + who: '你的原话', + pos: 2, + }); + const { getByText } = render(); + expect(getByText('原文内容')).toBeDefined(); + expect(getByText(/第 2 条消息/)).toBeDefined(); + }); + + it('点击 ✕ 移除引用并同步草稿', () => { + quoteSelectStore.getState().addQuote({ + marker: 'M1', + text: '原文', + who: 'AI 的回答', + pos: 1, + }); + quoteSelectStore.setState({ draft: 'M1' }); + const { getAllByRole } = render(); + fireEvent.click(getAllByRole('button')[0]); + expect(quoteSelectStore.getState().quotes).toHaveLength(0); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/QuoteSelect/QuoteSelect.tsx b/src/web-ui/src/flow_chat/components/QuoteSelect/QuoteSelect.tsx new file mode 100644 index 0000000000..7998e5813e --- /dev/null +++ b/src/web-ui/src/flow_chat/components/QuoteSelect/QuoteSelect.tsx @@ -0,0 +1,168 @@ +/** + * QuoteSelect — 选中对话消息文字 → 浮动菜单「复制文字 / 添加到对话」 + * + * 对齐 dsh @deepseek-ai/dsh-quote-select 与 qoderwork 的「选中 → 添加到对话」工作流。 + * 纯客户端组件:只监听事件 + 渲染浮动菜单(createPortal),自身不输出布局节点。 + * + * 依赖的现有契约(均已核实,见 README §2): + * - C1: user-message-item 行:data-bf-component + data-turn-id + * - C2: model-round-item 行:data-bf-component(无 data-turn-id) + * - 浮层宿主: getAppearanceOverlayHost() + * - 写入: quoteSelectStore.fillIntoComposer → C3/C5 + */ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; +import { useI18n } from '@/infrastructure/i18n'; +import { quoteSelectStore, makeMarker } from './quoteSelectStore'; +import { pickFromSelection, type QuotePick } from './quoteSelectPick'; + +export const QuoteSelect: React.FC = () => { + const { t } = useI18n('flow-chat'); + const [pick, setPick] = useState(null); + const [copied, setCopied] = useState(false); + const menuRef = useRef(null); + const selTimerRef = useRef(null); + + const hideMenu = useCallback(() => { + setPick(null); + setCopied(false); + }, []); + + const showOrHide = useCallback(() => { + const next = pickFromSelection(); + setPick(next); + if (!next) setCopied(false); + }, []); + + useEffect(() => { + const onMouseUp = (e: MouseEvent) => { + if (e.button !== 0) return; // 不抢右键系统菜单 + if (menuRef.current?.contains(e.target as Node)) return; + window.setTimeout(showOrHide, 0); + }; + const onSelectionChange = () => { + if (selTimerRef.current) window.clearTimeout(selTimerRef.current); + selTimerRef.current = window.setTimeout(showOrHide, 250); + }; + const onDocMouseDown = (e: MouseEvent) => { + if (menuRef.current?.contains(e.target as Node)) return; + hideMenu(); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') hideMenu(); + }; + const onHide = () => hideMenu(); + + document.addEventListener('mouseup', onMouseUp); + document.addEventListener('selectionchange', onSelectionChange); + document.addEventListener('mousedown', onDocMouseDown); + document.addEventListener('keydown', onKeyDown); + document.addEventListener('contextmenu', onHide); + window.addEventListener('scroll', onHide, true); + window.addEventListener('resize', onHide); + window.addEventListener('blur', onHide); + + return () => { + document.removeEventListener('mouseup', onMouseUp); + document.removeEventListener('selectionchange', onSelectionChange); + document.removeEventListener('mousedown', onDocMouseDown); + document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('contextmenu', onHide); + window.removeEventListener('scroll', onHide, true); + window.removeEventListener('resize', onHide); + window.removeEventListener('blur', onHide); + if (selTimerRef.current) window.clearTimeout(selTimerRef.current); + }; + }, [hideMenu, showOrHide]); + + const handleCopy = useCallback(async () => { + if (!pick) return; + const done = () => { + setCopied(true); + window.setTimeout(() => { + setPick((cur) => (cur === pick ? null : cur)); + setCopied(false); + }, 900); + }; + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(pick.text).then(done).catch(() => { + legacyCopy(pick.text); + done(); + }); + } else { + legacyCopy(pick.text); + done(); + } + }, [pick]); + + const handleAdd = useCallback(() => { + if (!pick) return; + const who = pick.who === 'user' ? t('quote.sourceUser') : t('quote.sourceAssistant'); + const marker = makeMarker(who, pick.pos, pick.text); + quoteSelectStore.getState().addQuote({ marker, text: pick.text, who, pos: pick.pos }); + quoteSelectStore.getState().fillIntoComposer(marker); + hideMenu(); + }, [hideMenu, pick, t]); + + if (!pick) return null; + + // 浮动菜单定位:选区上方居中,越界翻转;按钮按下不抢焦点、不取消选中 + const host = getAppearanceOverlayHost() ?? document.body; + const rect = pick.rect; + const MENU_W = 220; + const MENU_H = 38; + let left = rect.left + rect.width / 2 - MENU_W / 2; + left = Math.max(8, Math.min(left, window.innerWidth - MENU_W - 8)); + let top = rect.top - MENU_H - 8; + if (top < 8) top = Math.min(rect.bottom + 8, window.innerHeight - MENU_H - 8); + + return createPortal( +
e.preventDefault()} + onMouseUp={(e) => e.stopPropagation()} + > + + +
, + host, + ); +}; + +function legacyCopy(text: string): boolean { + try { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(ta); + return ok; + } catch { + return false; + } +} + +export default QuoteSelect; diff --git a/src/web-ui/src/flow_chat/components/QuoteSelect/quoteSelectPick.ts b/src/web-ui/src/flow_chat/components/QuoteSelect/quoteSelectPick.ts new file mode 100644 index 0000000000..8ee101a449 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/QuoteSelect/quoteSelectPick.ts @@ -0,0 +1,56 @@ +/** + * quoteSelectPick — 选区提取(纯函数,与组件分离以符合 react-refresh 规则) + * + * 依赖的现有契约: + * - C1: user-message-item 行(data-bf-component + data-turn-id) + * - C2: model-round-item 行(data-bf-component) + */ +export const QUOTE_MESSAGE_SELECTOR = [ + '[data-bf-component="user-message-item"]', + '[data-bf-component="model-round-item"]', +].join(', '); + +export interface QuotePick { + text: string; + /** user = 你的原话;assistant = AI 的回答 */ + who: 'user' | 'assistant'; + turnId: string | null; + /** 消息在可见列表中的序号(1-based,DOM 兜底;精确序号见 README 风险项 R2) */ + pos: number | null; + rect: DOMRect; +} + +/** 从当前选区提取引用信息;选区不在消息行内 / 折叠 / 空文本时返回 null */ +export function pickFromSelection(): QuotePick | null { + const sel = window.getSelection(); + if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null; + const text = sel.toString(); + if (!text || !text.trim()) return null; + + const range = sel.getRangeAt(0); + const start = sel.anchorNode; + if (!start || !start.parentElement) return null; + + const row = + start.nodeType === 1 + ? (start as Element).closest(QUOTE_MESSAGE_SELECTOR) + : start.parentElement.closest(QUOTE_MESSAGE_SELECTOR); + if (!row) return null; + + const component = row.getAttribute('data-bf-component'); + const scroll = row.closest('[data-bf-component="chat-pane"]'); + let pos: number | null = null; + if (scroll) { + const rows = Array.from(scroll.querySelectorAll(QUOTE_MESSAGE_SELECTOR)); + const idx = rows.indexOf(row); + if (idx !== -1) pos = idx + 1; + } + + return { + text: text.trim(), + who: component === 'user-message-item' ? 'user' : 'assistant', + turnId: row.getAttribute('data-turn-id'), + pos, + rect: range.getBoundingClientRect(), + }; +} diff --git a/src/web-ui/src/flow_chat/components/QuoteSelect/quoteSelectStore.ts b/src/web-ui/src/flow_chat/components/QuoteSelect/quoteSelectStore.ts new file mode 100644 index 0000000000..e8d2d7a155 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/QuoteSelect/quoteSelectStore.ts @@ -0,0 +1,95 @@ +/** + * quoteSelectStore — 「选中→引用」的共享状态与输入框写入逻辑 + * + * 依赖的现有契约(均已核实,见 README §2): + * - C3: globalEventBus.emit('fill-chat-input', { content, mode }) 写输入框 + * - C5: globalEventBus.emit('chat-input:get-state', { getValue }) 读输入框 + * + * 行为对齐 dsh @deepseek-ai/dsh-quote-select: + * - 引用 marker 在上,已有草稿在下 + * - 草稿变化时,marker 被删则摘除对应卡片 + * - 删卡片时,从草稿中移除 marker 并压缩多余空行 + */ +import { create } from 'zustand'; +import { globalEventBus } from '@/infrastructure/event-bus'; + +export const MAX_QUOTE_LEN = 2000; + +export interface Quote { + id: string; + marker: string; + text: string; + who: string; + pos?: number | null; +} + +export function makeMarker(whoDesc: string, pos: number | null | undefined, text: string): string { + const body = + text.length > MAX_QUOTE_LEN + ? `${text.slice(0, MAX_QUOTE_LEN)}…(原文更长,已截断)` + : text; + // 注意:dsh 原版在无位置时输出「对话对话历史」(模板拼接 bug),此处修正 + const head = pos + ? `【引用自对话第 ${pos} 条消息(${whoDesc})】` + : `【引用自对话历史(${whoDesc})】`; + return `${head}「${body}」`; +} + +export interface QuoteSelectState { + quotes: Quote[]; + draft: string; + addQuote: (q: Omit) => void; + removeQuote: (id: string) => void; + syncDraft: (draft: string) => void; + clearAll: () => void; + /** 把 marker 写入输入框(引用在上、草稿在下),并保留光标可编辑 */ + fillIntoComposer: (marker: string) => void; +} + +/** 通过 chat-input:get-state 同步读取当前草稿(现有实现为同步回填闭包) */ +function readCurrentDraft(): string { + let value = ''; + try { + globalEventBus.emit('chat-input:get-state', { + getValue: (v: string) => { + value = v; + }, + }); + } catch { + // 事件无监听者时忽略,按空草稿处理 + } + return value; +} + +export const quoteSelectStore = create((set, get) => ({ + quotes: [], + draft: '', + + addQuote: (q) => + set((s) => ({ + quotes: [...s.quotes, { ...q, id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}` }], + })), + + removeQuote: (id) => { + const q = get().quotes.find((x) => x.id === id); + set((s) => ({ quotes: s.quotes.filter((x) => x.id !== id) })); + if (!q) return; + const next = (get().draft ?? '').split(q.marker).join('').replace(/\n{3,}/g, '\n\n'); + globalEventBus.emit('fill-chat-input', { content: next, mode: 'replace' }); + }, + + syncDraft: (draft) => + set((s) => { + if (s.quotes.length === 0) return { draft }; + const keep = s.quotes.filter((q) => draft.includes(q.marker)); + return { draft, quotes: keep.length === s.quotes.length ? s.quotes : keep }; + }), + + clearAll: () => set({ quotes: [], draft: '' }), + + fillIntoComposer: (marker) => { + const current = readCurrentDraft(); + const next = current.trim() ? `${marker}\n\n${current}` : marker; + globalEventBus.emit('fill-chat-input', { content: next, mode: 'replace' }); + }, +}));