The assistant can pick connectors and show subagent steps, and the databases page is replaced by connections, sources, and a dashboard. Co-authored-by: Cursor <cursoragent@cursor.com>
743 lines
30 KiB
TypeScript
743 lines
30 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
|
import { v4 as uuidv4 } from 'uuid'
|
|
import { useFeatureFlags } from '../app/providers/FeatureFlagProvider'
|
|
import { useConversations } from '../app/workspace/ConversationsContext'
|
|
import { useAssistantRuntime } from '../app/workspace/AssistantRuntimeContext'
|
|
import { ensureConversation } from '../features/assistant/api/conversations.api'
|
|
import { replyQuestion, rejectQuestion } from '../features/assistant/api/chat.api'
|
|
import { listDatasources, listKnowledgeSpaces, listSkills } from '../features/assistant/api/resources.api'
|
|
import { listConnectors } from '../features/connectors/api'
|
|
import { messagesToTurns } from '../features/assistant/adapters/runToTurn'
|
|
import { collectPinnedResources, collectTurnInputFiles, humanForView } from '../features/assistant/adapters/historyToMessages'
|
|
import { ExecutionPanel } from '../features/assistant/execution/ExecutionPanel'
|
|
import { EnhancedChatInput } from '../features/assistant/input/EnhancedChatInput'
|
|
import { ToolsBar } from '../features/assistant/input/ToolsBar'
|
|
import { QuestionDock } from '../features/assistant/questions/QuestionDock'
|
|
import type { ResourceOption } from '../features/assistant/types/chat'
|
|
import {
|
|
kamidsoFileToSnapshot,
|
|
listWorkspaceFiles,
|
|
previewFile,
|
|
sessionFilesApi,
|
|
snapshotsForSend,
|
|
useSessionFiles,
|
|
type KamidsoFile,
|
|
type SessionFileSnapshot,
|
|
} from '../features/assistant/session-files'
|
|
import { DATA_FILE_ACCEPT, isDataFilename } from '../features/assistant/session-files/fileKind'
|
|
import { FileTitleModal } from '../features/assistant/session-files/FileTitleModal'
|
|
import AttachmentPreview, {
|
|
AttachmentPreviewCloseButton,
|
|
AttachmentPreviewPanelTitle,
|
|
} from '../features/assistant/session-files/AttachmentPreview'
|
|
import { ChatMessageList } from '../features/assistant/workspace/ChatMessageList'
|
|
import { ChatWelcome } from '../features/assistant/workspace/ChatWelcome'
|
|
import { ConversationContextBar } from '../features/assistant/workspace/ConversationContextBar'
|
|
import { conversationWidthClass } from '../features/assistant/workspace/conversationLayout'
|
|
import { Drawer } from '../components/ui/Drawer'
|
|
import { t } from '../i18n'
|
|
import { skillNeedsSetup } from '../features/settings/types'
|
|
import { skillSettingsPath } from '../features/settings/api'
|
|
import { ConfirmDialog } from '../components/ui/ConfirmDialog'
|
|
|
|
export function AssistantPage() {
|
|
const { developerMode, isEnabled } = useFeatureFlags()
|
|
const connectorsEnabled = isEnabled('connectors')
|
|
const location = useLocation()
|
|
const navigate = useNavigate()
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
const convId = searchParams.get('id')
|
|
const [conversationId, setConversationId] = useState<string | null>(convId)
|
|
const [query, setQuery] = useState('')
|
|
const [databases, setDatabases] = useState<ResourceOption[]>([])
|
|
const [knowledge, setKnowledge] = useState<ResourceOption[]>([])
|
|
const [connectors, setConnectors] = useState<ResourceOption[]>([])
|
|
const [skills, setSkills] = useState<ResourceOption[]>([])
|
|
const [selectedDbs, setSelectedDbs] = useState<ResourceOption[]>([])
|
|
const [selectedConnectors, setSelectedConnectors] = useState<ResourceOption[]>([])
|
|
const [selectedKnowledge, setSelectedKnowledge] = useState<ResourceOption | null>(null)
|
|
const [selectedSkill, setSelectedSkill] = useState<ResourceOption | null>(null)
|
|
const [reasoningMode, setReasoningMode] = useState<'fast' | 'medium' | 'deep'>('fast')
|
|
const [reportMode, setReportMode] = useState(false)
|
|
const [pendingSkillSetup, setPendingSkillSetup] = useState<ResourceOption | null>(null)
|
|
const { refresh: refreshConversations } = useConversations()
|
|
const [panelCollapsed, setPanelCollapsed] = useState(false)
|
|
const [preview, setPreview] = useState<{
|
|
loading: boolean
|
|
snapshot: Awaited<ReturnType<typeof previewFile>> | null
|
|
error: string | null
|
|
}>({ loading: false, snapshot: null, error: null })
|
|
const [selectedStepId, setSelectedStepId] = useState<string | null>(null)
|
|
const followLiveStepRef = useRef(true)
|
|
const [mobilePanelOpen, setMobilePanelOpen] = useState(false)
|
|
const [libraryFiles, setLibraryFiles] = useState<KamidsoFile[]>([])
|
|
const [pendingUpload, setPendingUpload] = useState<File[] | null>(null)
|
|
const libraryFileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
const sessionFiles = useSessionFiles({ api: sessionFilesApi })
|
|
const stream = useAssistantRuntime()
|
|
const presetApplied = useRef(false)
|
|
const newChatToken = useRef<number | null>(null)
|
|
const ignoreConvRestore = useRef(false)
|
|
const skipHistoryRestoreFor = useRef<string | null>(null)
|
|
const restoredFilesFor = useRef<string | null>(null)
|
|
const restoredCatalogFor = useRef<string | null>(null)
|
|
|
|
const refreshLibrary = useCallback(() => {
|
|
void listWorkspaceFiles()
|
|
.then(setLibraryFiles)
|
|
.catch(() => undefined)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
void Promise.all([listDatasources(), listKnowledgeSpaces(), listSkills()]).then(([d, k, s]) => {
|
|
setDatabases(d)
|
|
setKnowledge(k)
|
|
setSkills(s)
|
|
})
|
|
refreshLibrary()
|
|
}, [refreshLibrary])
|
|
|
|
useEffect(() => {
|
|
if (!connectorsEnabled) {
|
|
setConnectors([])
|
|
setSelectedConnectors([])
|
|
return
|
|
}
|
|
void listConnectors()
|
|
.then((rows) =>
|
|
setConnectors(
|
|
rows
|
|
.filter((item) => item.status === 'active')
|
|
.map((item) => ({
|
|
id: item.id,
|
|
name: item.display_name,
|
|
type: 'didar',
|
|
})),
|
|
),
|
|
)
|
|
.catch(() => setConnectors([]))
|
|
}, [connectorsEnabled])
|
|
|
|
useEffect(() => {
|
|
const skillId = searchParams.get('skill')
|
|
if (!skillId || !skills.length || convId) return
|
|
const match = skills.find((item) => item.id === skillId)
|
|
if (!match) return
|
|
if (searchParams.get('configured') === '1' || !skillNeedsSetup(match)) {
|
|
setSelectedSkill(match)
|
|
setPendingSkillSetup(null)
|
|
return
|
|
}
|
|
setPendingSkillSetup(match)
|
|
}, [convId, searchParams, skills])
|
|
|
|
useEffect(() => {
|
|
const state = location.state as { preset?: string; newChat?: number } | null
|
|
if (state?.preset && !presetApplied.current) {
|
|
presetApplied.current = true
|
|
setQuery(state.preset)
|
|
}
|
|
if (state?.newChat && state.newChat !== newChatToken.current) {
|
|
newChatToken.current = state.newChat
|
|
ignoreConvRestore.current = true
|
|
stream.resetConversation()
|
|
sessionFiles.resetSession()
|
|
setConversationId(null)
|
|
setSelectedDbs([])
|
|
setSelectedConnectors([])
|
|
setSelectedKnowledge(null)
|
|
setSelectedSkill(null)
|
|
setSelectedStepId(null)
|
|
followLiveStepRef.current = true
|
|
setPanelCollapsed(false)
|
|
setQuery('')
|
|
skipHistoryRestoreFor.current = null
|
|
restoredFilesFor.current = null
|
|
restoredCatalogFor.current = null
|
|
if (searchParams.get('id') || searchParams.get('run')) {
|
|
setSearchParams({}, { replace: true })
|
|
}
|
|
}
|
|
// Reset only when navigation state changes (new chat / preset).
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [location.state])
|
|
|
|
useEffect(() => {
|
|
if (ignoreConvRestore.current) {
|
|
if (!convId) ignoreConvRestore.current = false
|
|
return
|
|
}
|
|
if (!convId) {
|
|
if (stream.conversationId && (stream.loading || stream.messages.length > 0)) {
|
|
setConversationId(stream.conversationId)
|
|
setSearchParams({ id: stream.conversationId }, { replace: true })
|
|
return
|
|
}
|
|
setConversationId(null)
|
|
return
|
|
}
|
|
if (skipHistoryRestoreFor.current === convId) {
|
|
skipHistoryRestoreFor.current = null
|
|
setConversationId(convId)
|
|
restoredFilesFor.current = convId
|
|
restoredCatalogFor.current = convId
|
|
return
|
|
}
|
|
if (convId === stream.conversationId) {
|
|
setConversationId(convId)
|
|
return
|
|
}
|
|
setConversationId(convId)
|
|
setSelectedStepId(null)
|
|
followLiveStepRef.current = true
|
|
if (sessionFiles.sessionId !== convId) {
|
|
if (sessionFiles.sessionId) sessionFiles.resetSession()
|
|
setSelectedDbs([])
|
|
setSelectedConnectors([])
|
|
setSelectedKnowledge(null)
|
|
setSelectedSkill(null)
|
|
restoredFilesFor.current = null
|
|
restoredCatalogFor.current = null
|
|
}
|
|
void stream.restoreConversation(convId)
|
|
// Restore when the conversation id in the URL changes, not when session file drafts update.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [convId, stream.conversationId, stream.restoreConversation])
|
|
|
|
useEffect(() => {
|
|
if (sessionFiles.files.length > 0 || sessionFiles.legacyFile) {
|
|
setSelectedSkill(null)
|
|
}
|
|
}, [sessionFiles.files.length, sessionFiles.legacyFile])
|
|
|
|
useEffect(() => {
|
|
if (!convId || stream.conversationId !== convId || stream.messages.length === 0) return
|
|
const pins = collectPinnedResources(stream.messages)
|
|
if (restoredFilesFor.current !== convId) {
|
|
restoredFilesFor.current = convId
|
|
if (pins.files.length) sessionFiles.attachLibraryFiles(pins.files, convId)
|
|
}
|
|
if (restoredCatalogFor.current === convId) return
|
|
if (!databases.length && !knowledge.length && !connectors.length) return
|
|
restoredCatalogFor.current = convId
|
|
if (pins.databases.length) {
|
|
const matches = pins.databases
|
|
.map((pin) =>
|
|
databases.find((item) => item.id === pin.id || item.name === pin.db_name),
|
|
)
|
|
.filter((item): item is ResourceOption => Boolean(item))
|
|
if (matches.length) setSelectedDbs(matches)
|
|
}
|
|
if (pins.knowledge) {
|
|
const match = knowledge.find(
|
|
(item) => item.id === pins.knowledge?.id || item.name === pins.knowledge?.name,
|
|
)
|
|
if (match) setSelectedKnowledge(match)
|
|
}
|
|
if (pins.connectors.length) {
|
|
const matches = pins.connectors
|
|
.map((pin) => connectors.find((item) => item.id === pin.id || item.name === pin.name))
|
|
.filter((item): item is ResourceOption => Boolean(item))
|
|
if (matches.length) setSelectedConnectors(matches)
|
|
}
|
|
}, [convId, connectors, databases, knowledge, sessionFiles.attachLibraryFiles, stream.conversationId, stream.messages])
|
|
|
|
const rounds = useMemo(() => messagesToTurns(stream.messages), [stream.messages])
|
|
const isWelcome = rounds.length === 0
|
|
const activeExecution = stream.activeViewId ? stream.executionMap[stream.activeViewId] : undefined
|
|
const showExecutionPanel = !isWelcome && !panelCollapsed && (activeExecution?.steps.length ?? 0) > 0
|
|
const activeView = stream.messages.find((m) => m.id === stream.activeViewId && m.role === 'view')
|
|
const activeStepId = selectedStepId || activeExecution?.activeStepId
|
|
|
|
useEffect(() => {
|
|
followLiveStepRef.current = true
|
|
setSelectedStepId(null)
|
|
}, [stream.activeViewId])
|
|
|
|
useEffect(() => {
|
|
if (!activeExecution?.steps.length) return
|
|
if (!followLiveStepRef.current && selectedStepId) return
|
|
const running = [...activeExecution.steps].reverse().find((step) => step.status === 'running')
|
|
const target = running ?? activeExecution.steps[activeExecution.steps.length - 1]
|
|
if (selectedStepId !== target.id) setSelectedStepId(target.id)
|
|
}, [activeExecution?.steps, selectedStepId])
|
|
|
|
const handleStepSelect = useCallback((stepId: string) => {
|
|
followLiveStepRef.current = false
|
|
setSelectedStepId(stepId)
|
|
}, [])
|
|
|
|
const handleOpenExecution = useCallback((viewId: string, stepId?: string) => {
|
|
// Opening the panel from chat keeps live follow unless the run already finished.
|
|
if (stepId) {
|
|
if (!stream.loading) followLiveStepRef.current = false
|
|
setSelectedStepId(stepId)
|
|
}
|
|
setPanelCollapsed(false)
|
|
if (window.innerWidth < 1024) setMobilePanelOpen(true)
|
|
if (viewId !== stream.activeViewId) {
|
|
const view = stream.messages.find((m) => m.id === viewId && m.role === 'view')
|
|
if (view) stream.setActiveViewId(viewId)
|
|
}
|
|
}, [stream])
|
|
|
|
const panelInputFiles = useMemo(
|
|
() =>
|
|
collectTurnInputFiles(
|
|
stream.messages,
|
|
sessionFiles.files.map((draft) => draft.snapshot),
|
|
),
|
|
[stream.messages, sessionFiles.files],
|
|
)
|
|
const activeHuman = useMemo(
|
|
() => humanForView(stream.messages, stream.activeViewId),
|
|
[stream.messages, stream.activeViewId],
|
|
)
|
|
|
|
const executionPanelProps = {
|
|
steps: activeExecution?.steps || [],
|
|
outputs: activeExecution?.outputs || {},
|
|
activeStepId,
|
|
onStepSelect: handleStepSelect,
|
|
taskPlan: activeView?.taskPlan?.length ? activeView.taskPlan : stream.taskPlan,
|
|
summary: activeView?.context,
|
|
artifacts: stream.artifacts,
|
|
inputFiles: panelInputFiles,
|
|
attachedKnowledge: selectedKnowledge
|
|
? { name: selectedKnowledge.name, id: selectedKnowledge.id }
|
|
: activeHuman?.attachedKnowledge,
|
|
attachedDb: selectedDbs[0]
|
|
? { db_name: selectedDbs[0].name, db_type: selectedDbs[0].type || '' }
|
|
: activeHuman?.attachedDb,
|
|
attachedConnectors: selectedConnectors.map((item) => ({ id: item.id, name: item.name })),
|
|
loading: stream.loading,
|
|
developerMode,
|
|
}
|
|
|
|
const resolveConversationId = useCallback(async () => {
|
|
const id = await ensureConversation(conversationId)
|
|
if (id !== conversationId) {
|
|
skipHistoryRestoreFor.current = id
|
|
restoredFilesFor.current = id
|
|
restoredCatalogFor.current = id
|
|
setConversationId(id)
|
|
setSearchParams({ id }, { replace: true })
|
|
}
|
|
return id
|
|
}, [conversationId, setSearchParams])
|
|
|
|
const handleSend = useCallback(async () => {
|
|
const text = query.trim()
|
|
if (!text && sessionFiles.files.length === 0 && !sessionFiles.legacyFile) return
|
|
setQuery('')
|
|
|
|
const currentConvId = await resolveConversationId()
|
|
|
|
let sendSnapshot = null
|
|
if (sessionFiles.files.length > 0 || sessionFiles.legacyFile) {
|
|
if (sessionFiles.hasHardFailures) {
|
|
setQuery(text)
|
|
return
|
|
}
|
|
sendSnapshot = await sessionFiles.prepare(currentConvId)
|
|
}
|
|
|
|
const resources: Array<{ id: string; type: 'file' | 'datasource' | 'knowledge' | 'connector'; name?: string }> = []
|
|
if (sendSnapshot) {
|
|
for (const file of snapshotsForSend(sendSnapshot)) {
|
|
resources.push({ id: file.file_id, type: 'file', name: file.name })
|
|
}
|
|
}
|
|
for (const item of selectedDbs) {
|
|
resources.push({ id: item.id, type: 'datasource', name: item.name })
|
|
}
|
|
for (const item of selectedConnectors) {
|
|
resources.push({ id: item.id, type: 'connector', name: item.name })
|
|
}
|
|
if (selectedKnowledge) resources.push({ id: selectedKnowledge.id, type: 'knowledge', name: selectedKnowledge.name })
|
|
|
|
const humanId = uuidv4()
|
|
const viewId = uuidv4()
|
|
const order = Math.floor(stream.messages.length / 2) + 1
|
|
|
|
const attached = sendSnapshot ? snapshotsForSend(sendSnapshot) : []
|
|
const hasAttachedFiles = attached.length > 0
|
|
|
|
void stream
|
|
.send({
|
|
conversationId: currentConvId,
|
|
query: text || 'فایل را تحلیل کن.',
|
|
skillId: selectedSkill?.id,
|
|
reasoningMode,
|
|
reportMode,
|
|
resources,
|
|
humanMsg: {
|
|
id: humanId,
|
|
role: 'human',
|
|
context: text,
|
|
order,
|
|
attachedFiles: hasAttachedFiles ? attached : undefined,
|
|
attachedDb: selectedDbs[0]
|
|
? { db_name: selectedDbs[0].name, db_type: selectedDbs[0].type || '' }
|
|
: undefined,
|
|
attachedDatabases: selectedDbs.map((item) => ({
|
|
id: item.id,
|
|
db_name: item.name,
|
|
db_type: item.type || '',
|
|
})),
|
|
attachedSkill: selectedSkill
|
|
? { name: selectedSkill.name, id: selectedSkill.id }
|
|
: undefined,
|
|
attachedKnowledge: selectedKnowledge
|
|
? { name: selectedKnowledge.name, id: selectedKnowledge.id }
|
|
: undefined,
|
|
attachedConnectors: selectedConnectors.map((item) => ({ id: item.id, name: item.name })),
|
|
},
|
|
viewMsg: { id: viewId, role: 'view', context: '', order, thinking: true, reportMode },
|
|
})
|
|
.finally(() => {
|
|
void refreshConversations()
|
|
})
|
|
window.setTimeout(() => {
|
|
void refreshConversations()
|
|
}, 700)
|
|
}, [query, sessionFiles, selectedDbs, selectedKnowledge, selectedSkill, reasoningMode, reportMode, stream, resolveConversationId, refreshConversations])
|
|
|
|
const handlePreviewFile = useCallback(
|
|
async (file: SessionFileSnapshot) => {
|
|
if (!conversationId || file.file_id.startsWith('legacy:')) return
|
|
setPreview({ loading: true, snapshot: null, error: null })
|
|
try {
|
|
const snapshot = await previewFile(conversationId, file.file_id)
|
|
setPreview({ loading: false, snapshot, error: null })
|
|
} catch (err) {
|
|
setPreview({ loading: false, snapshot: null, error: err instanceof Error ? err.message : 'خطا' })
|
|
}
|
|
},
|
|
[conversationId],
|
|
)
|
|
|
|
const attachedFileIds = useMemo(
|
|
() =>
|
|
sessionFiles.files
|
|
.map((draft) => draft.snapshot?.file_id)
|
|
.filter((id): id is string => Boolean(id)),
|
|
[sessionFiles.files],
|
|
)
|
|
const pinnedFiles = useMemo(
|
|
() =>
|
|
sessionFiles.files
|
|
.map((draft) => draft.snapshot)
|
|
.filter((file): file is SessionFileSnapshot => Boolean(file?.file_id)),
|
|
[sessionFiles.files],
|
|
)
|
|
const hasConversationPins = Boolean(
|
|
pinnedFiles.length || selectedDbs.length || selectedKnowledge || selectedConnectors.length,
|
|
)
|
|
const dockDrafts = sessionFiles.files.filter((draft) => draft.upload.status !== 'done')
|
|
|
|
const handleRemovePinnedFile = useCallback(
|
|
(fileId: string) => {
|
|
const draft = sessionFiles.files.find((item) => item.snapshot?.file_id === fileId)
|
|
if (draft) sessionFiles.remove(draft.clientId)
|
|
},
|
|
[sessionFiles],
|
|
)
|
|
|
|
const handlePickLocalFiles = useCallback((files: FileList | File[]) => {
|
|
const next = Array.from(files).filter((file) => isDataFilename(file.name))
|
|
if (next.length) setPendingUpload(next)
|
|
}, [])
|
|
|
|
const handleConfirmUpload = useCallback(
|
|
async (files: File[], titles: string[]) => {
|
|
setPendingUpload(null)
|
|
const id = await resolveConversationId()
|
|
await sessionFiles.addFiles(files, id, { titles })
|
|
refreshLibrary()
|
|
},
|
|
[resolveConversationId, sessionFiles, refreshLibrary],
|
|
)
|
|
|
|
const handleToggleLibraryFile = useCallback(
|
|
(file: KamidsoFile) => {
|
|
const snapshot = kamidsoFileToSnapshot(file)
|
|
if (!snapshot.file_id) return
|
|
const existing = sessionFiles.files.find((draft) => draft.snapshot?.file_id === snapshot.file_id)
|
|
if (existing) {
|
|
sessionFiles.remove(existing.clientId)
|
|
return
|
|
}
|
|
sessionFiles.attachLibraryFiles(
|
|
[snapshot],
|
|
conversationId || stream.conversationId || sessionFiles.sessionId,
|
|
)
|
|
if (!conversationId && !stream.conversationId) {
|
|
void resolveConversationId().then((id) => {
|
|
sessionFiles.attachLibraryFiles([snapshot], id)
|
|
})
|
|
}
|
|
},
|
|
[conversationId, resolveConversationId, sessionFiles, stream.conversationId],
|
|
)
|
|
|
|
const toolsBar = (
|
|
<ToolsBar
|
|
databases={databases}
|
|
knowledge={knowledge}
|
|
skills={skills}
|
|
selectedDbs={selectedDbs}
|
|
selectedKnowledge={selectedKnowledge}
|
|
selectedSkill={selectedSkill}
|
|
onSelectDbs={setSelectedDbs}
|
|
onSelectKnowledge={setSelectedKnowledge}
|
|
onSelectSkill={setSelectedSkill}
|
|
reportMode={reportMode}
|
|
onReportModeChange={setReportMode}
|
|
reasoningMode={reasoningMode}
|
|
onReasoningModeChange={setReasoningMode}
|
|
libraryFiles={libraryFiles}
|
|
attachedFileIds={attachedFileIds}
|
|
onToggleLibraryFile={handleToggleLibraryFile}
|
|
onUploadNew={() => libraryFileInputRef.current?.click()}
|
|
connectors={connectors}
|
|
selectedConnectors={selectedConnectors}
|
|
onSelectConnectors={connectorsEnabled ? setSelectedConnectors : undefined}
|
|
/>
|
|
)
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 min-w-0 overflow-x-hidden" dir="ltr">
|
|
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden" dir="rtl">
|
|
{isWelcome ? (
|
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
|
<div className="lp-root relative flex min-h-0 min-w-0 flex-1 flex-col items-center justify-center overflow-x-hidden overflow-y-auto px-3">
|
|
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
|
|
<div className="lp-grid-bg absolute inset-0 [mask-image:radial-gradient(ellipse_70%_55%_at_50%_40%,#000_28%,transparent_100%)]" />
|
|
<div className="absolute -top-24 right-[-6rem] h-[22rem] w-[22rem] rounded-full bg-accent/15 blur-3xl" />
|
|
<div className="absolute top-10 left-[-8rem] h-[20rem] w-[20rem] rounded-full bg-violet-500/14 blur-3xl" />
|
|
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-surface to-transparent" />
|
|
</div>
|
|
<ChatWelcome onSuggestion={setQuery} />
|
|
<div className="relative z-10 w-full min-w-0 max-w-3xl">
|
|
<EnhancedChatInput
|
|
variant="hero"
|
|
query={query}
|
|
onQueryChange={setQuery}
|
|
onSend={() => void handleSend()}
|
|
onStop={() => void stream.cancel()}
|
|
loading={stream.loading}
|
|
drafts={sessionFiles.files}
|
|
legacyFiles={sessionFiles.legacyFile ? [sessionFiles.legacyFile] : []}
|
|
onRemoveFile={sessionFiles.remove}
|
|
onRetryFile={() => sessionFiles.retryFailed()}
|
|
onAddFiles={handlePickLocalFiles}
|
|
pinnedDatabases={selectedDbs}
|
|
pinnedKnowledge={selectedKnowledge}
|
|
pinnedConnectors={selectedConnectors}
|
|
onRemoveDatabase={(id) => setSelectedDbs((prev) => prev.filter((item) => item.id !== id))}
|
|
onClearKnowledge={() => setSelectedKnowledge(null)}
|
|
onRemoveConnector={(id: string) =>
|
|
setSelectedConnectors((prev) => prev.filter((item) => item.id !== id))
|
|
}
|
|
toolbar={toolsBar}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div
|
|
className={`grid h-full min-h-0 flex-1 grid-rows-[minmax(0,1fr)] ${
|
|
showExecutionPanel
|
|
? 'gap-2 p-2 md:gap-3 md:p-3 grid-cols-1 lg:grid-cols-[minmax(280px,0.85fr)_minmax(0,1.7fr)]'
|
|
: 'grid-cols-1'
|
|
}`}
|
|
dir="ltr"
|
|
>
|
|
{showExecutionPanel ? (
|
|
<div className="assistant-panel-enter hidden h-full min-h-0 lg:flex">
|
|
<ExecutionPanel
|
|
{...executionPanelProps}
|
|
className="h-full min-h-0 w-full"
|
|
onCollapse={() => setPanelCollapsed(true)}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
<div className="flex h-full min-h-0 min-w-0 flex-col" dir="rtl">
|
|
{stream.runStatus === 'waiting_for_user' ? (
|
|
<div className="mb-2 rounded-lg bg-warn-soft px-3 py-2 text-xs text-warn chat-enter">
|
|
{t('assistant.execution.waitingForUser')}
|
|
</div>
|
|
) : null}
|
|
{hasConversationPins ? (
|
|
<div className={`${conversationWidthClass} shrink-0 pb-1 ${showExecutionPanel ? 'pt-0' : 'pt-3'}`}>
|
|
<ConversationContextBar
|
|
files={pinnedFiles}
|
|
knowledge={
|
|
selectedKnowledge
|
|
? { id: selectedKnowledge.id, name: selectedKnowledge.name }
|
|
: undefined
|
|
}
|
|
databases={selectedDbs.map((item) => ({
|
|
id: item.id,
|
|
db_name: item.name,
|
|
db_type: item.type || '',
|
|
}))}
|
|
connectors={selectedConnectors.map((item) => ({ id: item.id, name: item.name }))}
|
|
onPreviewFile={(file) => void handlePreviewFile(file)}
|
|
onRemoveFile={handleRemovePinnedFile}
|
|
onRemoveDatabase={(key) =>
|
|
setSelectedDbs((prev) => prev.filter((item) => item.id !== key && item.name !== key))
|
|
}
|
|
onRemoveConnector={(id: string) =>
|
|
setSelectedConnectors((prev) => prev.filter((item) => item.id !== id))
|
|
}
|
|
onClearKnowledge={() => setSelectedKnowledge(null)}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
<ChatMessageList
|
|
rounds={rounds}
|
|
loading={stream.loading}
|
|
executionMap={stream.executionMap}
|
|
activeViewId={stream.activeViewId}
|
|
selectedStepId={selectedStepId}
|
|
htmlByViewId={stream.htmlByViewId}
|
|
thinkingByViewId={stream.thinkingByViewId}
|
|
liveTaskPlan={stream.taskPlan}
|
|
hideTurnAttachments={hasConversationPins}
|
|
onOpenExecution={handleOpenExecution}
|
|
onRetryStopped={() => void stream.retryStopped()}
|
|
onPreviewFile={(file) => void handlePreviewFile(file)}
|
|
/>
|
|
<EnhancedChatInput
|
|
variant="dock"
|
|
query={query}
|
|
onQueryChange={setQuery}
|
|
onSend={() => void handleSend()}
|
|
onStop={() => void stream.cancel()}
|
|
loading={stream.loading}
|
|
drafts={dockDrafts}
|
|
legacyFiles={sessionFiles.legacyFile ? [sessionFiles.legacyFile] : []}
|
|
onRemoveFile={sessionFiles.remove}
|
|
onRetryFile={() => sessionFiles.retryFailed()}
|
|
onAddFiles={handlePickLocalFiles}
|
|
pinnedDatabases={selectedDbs}
|
|
pinnedKnowledge={selectedKnowledge}
|
|
pinnedConnectors={selectedConnectors}
|
|
onRemoveDatabase={(id) => setSelectedDbs((prev) => prev.filter((item) => item.id !== id))}
|
|
onClearKnowledge={() => setSelectedKnowledge(null)}
|
|
onRemoveConnector={(id: string) =>
|
|
setSelectedConnectors((prev) => prev.filter((item) => item.id !== id))
|
|
}
|
|
toolbar={toolsBar}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{!isWelcome && panelCollapsed ? (
|
|
<button
|
|
type="button"
|
|
className="fixed inset-y-0 left-0 z-20 hidden w-8 flex-col justify-center rounded-r-lg border border-line bg-panel/95 text-ink-muted shadow hover:bg-surface-subtle hover:text-accent lg:flex"
|
|
onClick={() => setPanelCollapsed(false)}
|
|
aria-label={t('assistant.panel.showExecution')}
|
|
>
|
|
<span className="rotate-90 whitespace-nowrap text-[10px] font-medium tracking-wide">
|
|
{t('assistant.panel.showExecution')}
|
|
</span>
|
|
</button>
|
|
) : null}
|
|
|
|
<Drawer
|
|
open={mobilePanelOpen}
|
|
title={t('assistant.execution.title')}
|
|
onClose={() => setMobilePanelOpen(false)}
|
|
width="w-full max-w-lg"
|
|
side="start"
|
|
>
|
|
<div className="min-h-0 flex-1 overflow-hidden p-3">
|
|
<ExecutionPanel {...executionPanelProps} className="h-[calc(100dvh-8rem)]" />
|
|
</div>
|
|
</Drawer>
|
|
|
|
{stream.pendingQuestion && stream.runStatus === 'waiting_for_user' ? (
|
|
<QuestionDock
|
|
question={stream.pendingQuestion}
|
|
onReply={async (text) => {
|
|
const q = stream.pendingQuestion!
|
|
if (!q.runId || !q.id) return
|
|
try {
|
|
await replyQuestion(q.runId, q.id, text)
|
|
stream.setPendingQuestion(null)
|
|
} catch {
|
|
/* keep dock open so the user can retry */
|
|
}
|
|
}}
|
|
onReject={async () => {
|
|
const q = stream.pendingQuestion!
|
|
if (!q.runId || !q.id) return
|
|
try {
|
|
await rejectQuestion(q.runId, q.id)
|
|
stream.setPendingQuestion(null)
|
|
} catch {
|
|
/* keep dock open */
|
|
}
|
|
}}
|
|
/>
|
|
) : null}
|
|
|
|
<input
|
|
ref={libraryFileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept={DATA_FILE_ACCEPT}
|
|
className="hidden"
|
|
onChange={(event) => {
|
|
if (event.target.files?.length) handlePickLocalFiles(event.target.files)
|
|
event.target.value = ''
|
|
}}
|
|
/>
|
|
<FileTitleModal
|
|
files={pendingUpload}
|
|
onClose={() => setPendingUpload(null)}
|
|
onConfirm={(files, titles) => void handleConfirmUpload(files, titles)}
|
|
/>
|
|
|
|
{preview.loading || preview.snapshot || preview.error ? (
|
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-ink/40 p-4 md:items-center">
|
|
<div className="max-h-[80vh] w-full max-w-3xl overflow-hidden rounded-2xl border border-line bg-panel shadow-xl">
|
|
<div className="flex items-center justify-between border-b border-line px-4 py-3">
|
|
<AttachmentPreviewPanelTitle name={preview.snapshot?.name || t('assistant.files.preview')} />
|
|
<AttachmentPreviewCloseButton
|
|
onClick={() => setPreview({ loading: false, snapshot: null, error: null })}
|
|
/>
|
|
</div>
|
|
<div className="overflow-y-auto p-4">
|
|
<AttachmentPreview snapshot={preview.snapshot} loading={preview.loading} error={preview.error} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<ConfirmDialog
|
|
open={Boolean(pendingSkillSetup)}
|
|
title="تنظیمات این مهارت لازم است"
|
|
message={`برای انتخاب «${pendingSkillSetup?.name || 'این مهارت'}» باید تنظیمات کسبوکار سازمان را تکمیل کنید. قوانین ایران توسط کامیدسو مدیریت میشود و قابل تغییر نیست. آیا الان تنظیمات را انجام میدهید؟`}
|
|
confirmLabel="بله، تنظیم میکنم"
|
|
cancelLabel="فعلاً نه"
|
|
onConfirm={() => {
|
|
if (!pendingSkillSetup) return
|
|
navigate(skillSettingsPath(pendingSkillSetup.id, '/assistant'))
|
|
setPendingSkillSetup(null)
|
|
}}
|
|
onCancel={() => setPendingSkillSetup(null)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|