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 @@
overflow: hidden;
}
.scrollable {
flex-direction: column-reverse;
align-items: center;
flex-direction: column;
align-items: stretch;
display: flex;
overflow-x: hidden;
overflow-y: scroll;
......
// 问答功能独立首页
import type React from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { useLocalStorageState } from 'ahooks'
import styles from '../Home/Home.module.less'
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 type { RootState } from '@/store'
import { fetchLoginByToken, fetchLoginByUid } from '@/api/common'
import { getUserRolesFromRoute } from '@/lib/utils'
import { ChatEditor } from '@/components/ChatEditor'
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'
import { TacticsChatEditor } from '@/components/TacticsChatEditor'
export const TacticsHome: React.FC = () => {
const viteOutputObj = import.meta.env.VITE_OUTPUT_OBJ || 'open'
const dispatch = useAppDispatch()
const location = useLocation()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const hasFetched = useRef(false)
const { shouldNavigateToNewConversation, currentConversationId, shouldSendQuestion } = useAppSelector((state: RootState) => state.tactics)
const [token, setToken] = useLocalStorageState<string | undefined>('__TOKEN__', {
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 fromCollect = location.state?.fromCollect
......@@ -51,47 +89,12 @@ export const TacticsHome: React.FC = () => {
const handleCreateConversation = useCallback(async (question: string) => {
// 如果已有会话,直接调用 submit 接口提交问题,然后跳转到聊天页面
if (currentConversationId) {
// 停止之前的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
// 检查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,
},
() => {
// 在首页不需要处理消息,跳转到聊天页面后会自动刷新加载最新消息
navigate(`/tactics/chat/${currentConversationId}`, {
state: {
shouldSendQuestion: question,
tacticsMeta,
},
abortControllerRef.current.signal,
)
// 跳转到聊天页面查看结果
navigate(`/tactics/chat/${currentConversationId}`)
})
return
}
// 如果没有会话,才创建新会话
......@@ -102,7 +105,7 @@ export const TacticsHome: React.FC = () => {
shouldSendQuestion: question,
}),
)
}, [dispatch, currentConversationId, navigate])
}, [dispatch, currentConversationId, navigate, tacticsMeta])
// 监听导航到新对话
useEffect(() => {
......@@ -111,11 +114,13 @@ export const TacticsHome: React.FC = () => {
navigate(url, {
state: {
shouldSendQuestion,
tacticsMeta,
},
replace: true,
})
dispatch(clearTacticsNavigationFlag())
}
}, [shouldNavigateToNewConversation, currentConversationId, shouldSendQuestion, navigate, dispatch])
}, [shouldNavigateToNewConversation, currentConversationId, shouldSendQuestion, navigate, dispatch, tacticsMeta])
const login = useCallback(async () => {
if (hasFetched.current) {
......@@ -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 (
<div
......@@ -215,36 +191,12 @@ export const TacticsHome: React.FC = () => {
height: '100vh',
display: 'flex',
flexDirection: 'column',
width: '420px',
marginLeft: 'auto',
marginRight: 0,
position: 'fixed',
right: 0,
top: 0,
backgroundColor: '#FFFFFF',
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="flex-1 overflow-hidden flex flex-col">
......@@ -254,11 +206,9 @@ export const TacticsHome: React.FC = () => {
</div>
{/* 底部输入框 */}
<div className="box-border px-[16px] pb-[18px] pt-[12px] bg-white border-t border-gray-100">
<ChatEditor
showContentTips
<TacticsChatEditor
onSubmit={handleCreateConversation}
placeholders={RECOMMEND_QUESTIONS_OTHER}
hideTools
placeholders={['']}
/>
<div className="w-full text-center mt-[12px] text-[#3333334d] text-[12px]">
内容由AI模型生成,其准确性和完整性无法保证,仅供参考
......
......@@ -10,7 +10,7 @@ export const TacticsWelcome: React.FC = () => {
return (
<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">
<Avatar className="mr-[12px] flex-shrink-0" src={viteOutputObj === 'inner' ? AIIcon : AvatarBot} />
<motion.div
......@@ -27,7 +27,7 @@ export const TacticsWelcome: React.FC = () => {
</div>
</motion.div>
</div>
<div className="h-[20px] sm:h-[32px] w-full"></div>
<div className="h-[12px] sm:h-[24px] w-full"></div>
</div>
)
}
......@@ -15,9 +15,8 @@ const AppRoutesComponent: React.FC = () => {
<Route path="/chat/:id" element={<Chat />} />
</Route>
<Route path="/home" element={<Home />}></Route>
<Route path="/tactics" element={<TacticsHome />}>
<Route path="/tactics/chat/:id" element={<TacticsChat />} />
</Route>
<Route path="/tactics" element={<TacticsHome />} />
<Route path="/tactics/chat/:id" element={<TacticsChat />} />
<Route path="/collect" element={<Collect />} />
<Route path="/tools" element={<Tools />} />
<Route path="/protocol/:id" element={<Protocol />} />
......
......@@ -23,6 +23,23 @@ export function withRouteChangeHandler(WrappedComponent: React.ComponentType) {
const searchParams = new URLSearchParams(location.search)
const from = searchParams.get('from')
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,保留其他查询参数
const newSearchParams = new URLSearchParams(location.search)
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