Commit b4dc7dee by Liu

feat:新建会话逻辑&&重新分析&&清除记录

parent 887c7712
import React, { useRef, useState } from 'react'
import { Button, Tooltip } from '@heroui/react'
import SendIcon from '@/assets/svg/send.svg?react'
import { type WithAuthProps, withAuth } from '@/auth/withAuth'
import { useAppSelector } from '@/store/hook'
import type { RootState } from '@/store'
interface TacticsChatEditorProps {
onSubmit?: (value: string) => void
onChange?: (value: string) => void
placeholders?: string[]
initialValue?: string
}
const defaultPlaceholders: string[] = []
const TacticsChatEditorBase: React.FC<TacticsChatEditorProps & WithAuthProps> = ({
onSubmit,
onChange,
placeholders = defaultPlaceholders,
initialValue = '',
checkAuth,
}) => {
const [content, setContent] = useState(initialValue)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const isAsking = useAppSelector((state: RootState) => state.chat.isAsking)
const [placeholderIndex] = useState(0)
const currentPlaceholder = placeholders[placeholderIndex] || '输入您想问的问题吧'
const adjustHeight = () => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`
}
}
const handleChange = (value: string) => {
setContent(value)
onChange?.(value)
adjustHeight()
}
const handleSubmit = () => {
// eslint-disable-next-line no-console
console.log('[TacticsChatEditor] submit click', {
content,
isAsking,
hasOnSubmit: Boolean(onSubmit),
})
if (isAsking) {
// eslint-disable-next-line no-console
console.log('[TacticsChatEditor] blocked: isAsking true')
return
}
const authed = checkAuth()
// eslint-disable-next-line no-console
console.log('[TacticsChatEditor] auth checked', { authed })
if (!authed)
return
const trimmed = content.trim()
if (!trimmed) {
// eslint-disable-next-line no-console
console.log('[TacticsChatEditor] blocked: empty content')
return
}
// eslint-disable-next-line no-console
console.log('[TacticsChatEditor] fire onSubmit', { trimmed, hasOnSubmit: Boolean(onSubmit) })
try {
onSubmit?.(trimmed)
// eslint-disable-next-line no-console
console.log('[TacticsChatEditor] after onSubmit')
}
catch (error) {
console.error('[TacticsChatEditor] onSubmit error', error)
}
setContent('')
if (textareaRef.current) {
textareaRef.current.value = ''
adjustHeight()
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}
return (
<div className="flex items-end w-full h-auto relative mx-auto rounded-[24px] overflow-hidden transition duration-200 py-[8px] pl-[12px] pr-[12px] bg-white border border-[#0a0a0a78] shadow-[0_8px_12px_0_rgba(235,235,235,0.30)]">
<textarea
ref={textareaRef}
defaultValue={initialValue}
className="w-full min-h-[40px] max-h-[120px] p-2 rounded overflow-y-auto outline-none resize-none"
placeholder={currentPlaceholder}
onChange={e => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
/>
<Tooltip
isOpen={!content && !isAsking}
color="foreground"
content="请输入您的问题📖"
placement="top-end"
>
<Button
className="ask-send"
onPress={handleSubmit}
radius="full"
isDisabled={!content || isAsking}
isIconOnly
color="primary"
>
<SendIcon />
</Button>
</Tooltip>
</div>
)
}
export const TacticsChatEditor = withAuth(TacticsChatEditorBase)
export default TacticsChatEditor
...@@ -24,8 +24,8 @@ ...@@ -24,8 +24,8 @@
overflow: hidden; overflow: hidden;
} }
.scrollable { .scrollable {
flex-direction: column-reverse; flex-direction: column;
align-items: center; align-items: stretch;
display: flex; display: flex;
overflow-x: hidden; overflow-x: hidden;
overflow-y: scroll; overflow-y: scroll;
......
// 问答功能独立首页 // 问答功能独立首页
import type React from 'react' import type React from 'react'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { useLocalStorageState } from 'ahooks' import { useLocalStorageState } from 'ahooks'
import styles from '../Home/Home.module.less' import styles from '../Home/Home.module.less'
import { TacticsWelcome } from './components/TacticsWelcome' import { TacticsWelcome } from './components/TacticsWelcome'
import { clearTacticsNavigationFlag, createTacticsConversation, deleteTacticsConversations, fetchTacticsConversations } from '@/store/tacticsSlice' import { clearTacticsNavigationFlag, createTacticsConversation, fetchTacticsConversations } from '@/store/tacticsSlice'
import { useAppDispatch, useAppSelector } from '@/store/hook' import { useAppDispatch, useAppSelector } from '@/store/hook'
import type { RootState } from '@/store' import type { RootState } from '@/store'
import { fetchLoginByToken, fetchLoginByUid } from '@/api/common' import { fetchLoginByToken, fetchLoginByUid } from '@/api/common'
import { getUserRolesFromRoute } from '@/lib/utils' import { getUserRolesFromRoute } from '@/lib/utils'
import { ChatEditor } from '@/components/ChatEditor' import { TacticsChatEditor } from '@/components/TacticsChatEditor'
import { RECOMMEND_QUESTIONS_OTHER } from '@/config/recommendQuestion'
import { fetchCheckTokenApi, fetchStreamResponse } from '@/api/chat'
import DeleteIcon from '@/assets/svg/delete.svg?react'
import RefreshIcon from '@/assets/svg/refresh.svg?react'
export const TacticsHome: React.FC = () => { export const TacticsHome: React.FC = () => {
const viteOutputObj = import.meta.env.VITE_OUTPUT_OBJ || 'open' const viteOutputObj = import.meta.env.VITE_OUTPUT_OBJ || 'open'
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams()
const hasFetched = useRef(false) const hasFetched = useRef(false)
const { shouldNavigateToNewConversation, currentConversationId, shouldSendQuestion } = useAppSelector((state: RootState) => state.tactics) const { shouldNavigateToNewConversation, currentConversationId, shouldSendQuestion } = useAppSelector((state: RootState) => state.tactics)
const [token, setToken] = useLocalStorageState<string | undefined>('__TOKEN__', { const [token, setToken] = useLocalStorageState<string | undefined>('__TOKEN__', {
defaultValue: '', defaultValue: '',
}) })
const abortControllerRef = useRef<AbortController | null>(null)
// 仅在 from=tactics 且 version=2 时读取并缓存额外参数
const tacticsMetaFromSearch = useMemo(() => {
const from = searchParams.get('from')
const version = searchParams.get('version')
if (from !== 'tactics' || version !== '2') {
return undefined
}
return {
version,
taskId: searchParams.get('taskId') || undefined,
pinBeginTime: searchParams.get('pinBeginTime') || undefined,
pinEndTime: searchParams.get('pinEndTime') || undefined,
partOrAll: searchParams.get('partOrAll') || 'all',
channel: searchParams.get('channel') || 'AIphone',
channelName: searchParams.get('channelName') || undefined,
}
}, [searchParams])
const tacticsMetaFromStorage = useMemo(() => {
const raw = sessionStorage.getItem('tacticsMeta')
if (!raw) {
return undefined
}
try {
const parsed = JSON.parse(raw)
if (parsed?.from === 'tactics' && parsed?.version === '2') {
return parsed
}
}
catch {
// ignore
}
return undefined
}, [])
const tacticsMeta = useMemo(() => tacticsMetaFromSearch || tacticsMetaFromStorage, [tacticsMetaFromSearch, tacticsMetaFromStorage])
// 同步到 sessionStorage,便于跳转后读取;仅在有 meta 时写入,避免覆盖重定向缓存
useEffect(() => {
if (tacticsMeta) {
sessionStorage.setItem('tacticsMeta', JSON.stringify(tacticsMeta))
}
}, [tacticsMeta])
const initTacticsConversation = () => { const initTacticsConversation = () => {
const fromCollect = location.state?.fromCollect const fromCollect = location.state?.fromCollect
...@@ -51,47 +89,12 @@ export const TacticsHome: React.FC = () => { ...@@ -51,47 +89,12 @@ export const TacticsHome: React.FC = () => {
const handleCreateConversation = useCallback(async (question: string) => { const handleCreateConversation = useCallback(async (question: string) => {
// 如果已有会话,直接调用 submit 接口提交问题,然后跳转到聊天页面 // 如果已有会话,直接调用 submit 接口提交问题,然后跳转到聊天页面
if (currentConversationId) { if (currentConversationId) {
// 停止之前的请求 navigate(`/tactics/chat/${currentConversationId}`, {
if (abortControllerRef.current) { state: {
abortControllerRef.current.abort() shouldSendQuestion: question,
} tacticsMeta,
// 检查token
await fetchCheckTokenApi()
// 创建新的 AbortController
abortControllerRef.current = new AbortController()
let fetchUrl = `/conversation/api/conversation/mobile/v1/submit_question_stream`
const viteOutputObj = import.meta.env.VITE_OUTPUT_OBJ || 'open'
let proxy = ''
if (viteOutputObj === 'open') {
proxy = import.meta.env.MODE !== 'prod' ? '/api' : '/dev-sdream-api'
}
else {
proxy = import.meta.env.MODE === 'dev' ? '/api' : '/dev-sdream-api'
}
fetchUrl = proxy + fetchUrl
// 直接调用 submit 接口(消息流会在聊天页面处理,这里只负责发起请求)
fetchStreamResponse(
fetchUrl,
{
question,
conversationId: currentConversationId,
stream: true,
},
() => {
// 在首页不需要处理消息,跳转到聊天页面后会自动刷新加载最新消息
}, },
abortControllerRef.current.signal, })
)
// 跳转到聊天页面查看结果
navigate(`/tactics/chat/${currentConversationId}`)
return return
} }
// 如果没有会话,才创建新会话 // 如果没有会话,才创建新会话
...@@ -102,7 +105,7 @@ export const TacticsHome: React.FC = () => { ...@@ -102,7 +105,7 @@ export const TacticsHome: React.FC = () => {
shouldSendQuestion: question, shouldSendQuestion: question,
}), }),
) )
}, [dispatch, currentConversationId, navigate]) }, [dispatch, currentConversationId, navigate, tacticsMeta])
// 监听导航到新对话 // 监听导航到新对话
useEffect(() => { useEffect(() => {
...@@ -111,11 +114,13 @@ export const TacticsHome: React.FC = () => { ...@@ -111,11 +114,13 @@ export const TacticsHome: React.FC = () => {
navigate(url, { navigate(url, {
state: { state: {
shouldSendQuestion, shouldSendQuestion,
tacticsMeta,
}, },
replace: true,
}) })
dispatch(clearTacticsNavigationFlag()) dispatch(clearTacticsNavigationFlag())
} }
}, [shouldNavigateToNewConversation, currentConversationId, shouldSendQuestion, navigate, dispatch]) }, [shouldNavigateToNewConversation, currentConversationId, shouldSendQuestion, navigate, dispatch, tacticsMeta])
const login = useCallback(async () => { const login = useCallback(async () => {
if (hasFetched.current) { if (hasFetched.current) {
...@@ -178,35 +183,6 @@ export const TacticsHome: React.FC = () => { ...@@ -178,35 +183,6 @@ export const TacticsHome: React.FC = () => {
}, []) }, [])
// 处理清除记录 // 处理清除记录
const handleClearRecord = useCallback(async () => {
if (!currentConversationId) {
return
}
try {
await dispatch(deleteTacticsConversations([currentConversationId])).unwrap()
// 清除后创建新会话
dispatch(
createTacticsConversation({
conversationData: {},
shouldNavigate: false,
shouldSendQuestion: '',
}),
)
}
catch (error) {
console.error('清除记录失败:', error)
}
}, [currentConversationId, dispatch])
// 处理重新分析
const handleReanalyze = useCallback(async () => {
// 重新获取会话列表
await dispatch(fetchTacticsConversations())
// 如果有当前会话,可以重新加载
if (currentConversationId) {
navigate(`/tactics/chat/${currentConversationId}`)
}
}, [currentConversationId, dispatch, navigate])
return ( return (
<div <div
...@@ -215,36 +191,12 @@ export const TacticsHome: React.FC = () => { ...@@ -215,36 +191,12 @@ export const TacticsHome: React.FC = () => {
height: '100vh', height: '100vh',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
width: '420px',
marginLeft: 'auto', marginLeft: 'auto',
marginRight: 0, marginRight: 0,
position: 'fixed',
right: 0,
top: 0,
backgroundColor: '#FFFFFF', backgroundColor: '#FFFFFF',
zIndex: 1000, zIndex: 1000,
}} }}
> >
{/* 按钮区域 - 右侧展示 */}
<div className="flex justify-end items-center gap-[16px] px-[16px] pt-[16px] pb-[8px]">
<button
type="button"
onClick={handleClearRecord}
className="flex items-center gap-[4px] text-[#4A90E2] text-[14px] hover:opacity-80 transition-opacity cursor-pointer bg-transparent border-none outline-none"
>
<DeleteIcon className="w-[16px] h-[16px] text-[#B2B8C1]" />
<span>清除记录</span>
</button>
<button
type="button"
onClick={handleReanalyze}
className="flex items-center gap-[4px] text-[#4A90E2] text-[14px] hover:opacity-80 transition-opacity cursor-pointer bg-transparent border-none outline-none"
>
<RefreshIcon className="w-[16px] h-[16px] text-[#B2B8C1]" />
<span>重新分析</span>
</button>
</div>
<div className="h-full w-full flex flex-col"> <div className="h-full w-full flex flex-col">
{/* 主要内容区域 - 全屏显示 */} {/* 主要内容区域 - 全屏显示 */}
<div className="flex-1 overflow-hidden flex flex-col"> <div className="flex-1 overflow-hidden flex flex-col">
...@@ -254,11 +206,9 @@ export const TacticsHome: React.FC = () => { ...@@ -254,11 +206,9 @@ export const TacticsHome: React.FC = () => {
</div> </div>
{/* 底部输入框 */} {/* 底部输入框 */}
<div className="box-border px-[16px] pb-[18px] pt-[12px] bg-white border-t border-gray-100"> <div className="box-border px-[16px] pb-[18px] pt-[12px] bg-white border-t border-gray-100">
<ChatEditor <TacticsChatEditor
showContentTips
onSubmit={handleCreateConversation} onSubmit={handleCreateConversation}
placeholders={RECOMMEND_QUESTIONS_OTHER} placeholders={['']}
hideTools
/> />
<div className="w-full text-center mt-[12px] text-[#3333334d] text-[12px]"> <div className="w-full text-center mt-[12px] text-[#3333334d] text-[12px]">
内容由AI模型生成,其准确性和完整性无法保证,仅供参考 内容由AI模型生成,其准确性和完整性无法保证,仅供参考
......
...@@ -10,7 +10,7 @@ export const TacticsWelcome: React.FC = () => { ...@@ -10,7 +10,7 @@ export const TacticsWelcome: React.FC = () => {
return ( return (
<div className="chatWelcomeContainer w-full"> <div className="chatWelcomeContainer w-full">
<div className="h-[20px] sm:h-[32px] w-full"></div> <div className="h-[8px] sm:h-[16px] w-full"></div>
<div className="flex items-start"> <div className="flex items-start">
<Avatar className="mr-[12px] flex-shrink-0" src={viteOutputObj === 'inner' ? AIIcon : AvatarBot} /> <Avatar className="mr-[12px] flex-shrink-0" src={viteOutputObj === 'inner' ? AIIcon : AvatarBot} />
<motion.div <motion.div
...@@ -27,7 +27,7 @@ export const TacticsWelcome: React.FC = () => { ...@@ -27,7 +27,7 @@ export const TacticsWelcome: React.FC = () => {
</div> </div>
</motion.div> </motion.div>
</div> </div>
<div className="h-[20px] sm:h-[32px] w-full"></div> <div className="h-[12px] sm:h-[24px] w-full"></div>
</div> </div>
) )
} }
...@@ -15,9 +15,8 @@ const AppRoutesComponent: React.FC = () => { ...@@ -15,9 +15,8 @@ const AppRoutesComponent: React.FC = () => {
<Route path="/chat/:id" element={<Chat />} /> <Route path="/chat/:id" element={<Chat />} />
</Route> </Route>
<Route path="/home" element={<Home />}></Route> <Route path="/home" element={<Home />}></Route>
<Route path="/tactics" element={<TacticsHome />}> <Route path="/tactics" element={<TacticsHome />} />
<Route path="/tactics/chat/:id" element={<TacticsChat />} /> <Route path="/tactics/chat/:id" element={<TacticsChat />} />
</Route>
<Route path="/collect" element={<Collect />} /> <Route path="/collect" element={<Collect />} />
<Route path="/tools" element={<Tools />} /> <Route path="/tools" element={<Tools />} />
<Route path="/protocol/:id" element={<Protocol />} /> <Route path="/protocol/:id" element={<Protocol />} />
......
...@@ -23,6 +23,23 @@ export function withRouteChangeHandler(WrappedComponent: React.ComponentType) { ...@@ -23,6 +23,23 @@ export function withRouteChangeHandler(WrappedComponent: React.ComponentType) {
const searchParams = new URLSearchParams(location.search) const searchParams = new URLSearchParams(location.search)
const from = searchParams.get('from') const from = searchParams.get('from')
if (from === 'tactics') { if (from === 'tactics') {
// 在重定向前缓存查询参数,供 /tactics 页读取(避免 from 被清理导致丢失)
try {
const meta = {
version: searchParams.get('version') || undefined,
taskId: searchParams.get('taskId') || undefined,
pinBeginTime: searchParams.get('pinBeginTime') || undefined,
pinEndTime: searchParams.get('pinEndTime') || undefined,
partOrAll: searchParams.get('partOrAll') || 'all',
channel: searchParams.get('channel') || 'AIphone',
channelName: searchParams.get('channelName') || undefined,
from,
}
sessionStorage.setItem('tacticsMeta', JSON.stringify(meta))
}
catch {
// ignore
}
// 重定向到 /tactics,保留其他查询参数 // 重定向到 /tactics,保留其他查询参数
const newSearchParams = new URLSearchParams(location.search) const newSearchParams = new URLSearchParams(location.search)
newSearchParams.delete('from') newSearchParams.delete('from')
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment