mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-19 12:47:25 +08:00
feat(desktop): chat core with streaming, sessions, tool steps and markdown-it rendering
This commit is contained in:
1596
desktop/package-lock.json
generated
1596
desktop/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -18,17 +18,18 @@
|
|||||||
"dist:win": "npm run build && electron-builder --win"
|
"dist:win": "npm run build && electron-builder --win"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"lucide-react": "^1.21.0",
|
||||||
|
"markdown-it": "^14.2.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
"react-markdown": "^9.0.1",
|
"zustand": "^5.0.14"
|
||||||
"react-syntax-highlighter": "^15.6.1",
|
|
||||||
"lucide-react": "^0.460.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"concurrently": "^9.1.0",
|
"concurrently": "^9.1.0",
|
||||||
@@ -71,7 +72,9 @@
|
|||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
"target": "dmg",
|
"target": "dmg",
|
||||||
"arch": ["universal"]
|
"arch": [
|
||||||
|
"universal"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -80,7 +83,9 @@
|
|||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
"target": "nsis",
|
"target": "nsis",
|
||||||
"arch": ["x64"]
|
"arch": [
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,57 +1,129 @@
|
|||||||
import React, { useState, useRef, useCallback } from 'react'
|
import React, { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
|
||||||
|
import { Plus, Paperclip, Send, Square, X, File as FileIcon, Loader2 } from 'lucide-react'
|
||||||
import { t } from '../i18n'
|
import { t } from '../i18n'
|
||||||
import type { Attachment } from '../types'
|
import type { Attachment } from '../types'
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
|
|
||||||
|
export type ChatInputHandle = (text: string, attachments: Attachment[]) => void
|
||||||
|
|
||||||
|
interface SlashCommand {
|
||||||
|
cmd: string
|
||||||
|
desc: string
|
||||||
|
action: 'new' | 'clear'
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onSend: (message: string, attachments: Attachment[]) => void
|
onSend: (message: string, attachments: Attachment[]) => void
|
||||||
onNewChat: () => void
|
onNewChat: () => void
|
||||||
disabled: boolean
|
onStop: () => void
|
||||||
|
onClearContext: () => void
|
||||||
|
isStreaming: boolean
|
||||||
sessionId: string
|
sessionId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatInput: React.FC<ChatInputProps> = ({ onSend, onNewChat, disabled, sessionId }) => {
|
const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput(
|
||||||
|
{ onSend, onNewChat, onStop, onClearContext, isStreaming, sessionId },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [dragOver, setDragOver] = useState(false)
|
||||||
|
const [slashOpen, setSlashOpen] = useState(false)
|
||||||
|
const [slashIndex, setSlashIndex] = useState(0)
|
||||||
|
const composingRef = useRef(false)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const slashCommands: SlashCommand[] = [
|
||||||
|
{ cmd: '/new', desc: t('session_new'), action: 'new' },
|
||||||
|
{ cmd: '/clear', desc: t('chat_clear_context'), action: 'clear' },
|
||||||
|
]
|
||||||
|
const filtered = slashCommands.filter((c) => c.cmd.startsWith(text.trim().toLowerCase()))
|
||||||
|
|
||||||
|
const resetHeight = () => {
|
||||||
|
if (textareaRef.current) textareaRef.current.style.height = '42px'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow the parent to load a draft (e.g. when editing a past user message).
|
||||||
|
useImperativeHandle(ref, () => (draft: string, atts: Attachment[]) => {
|
||||||
|
setText(draft)
|
||||||
|
setAttachments(atts)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = textareaRef.current
|
||||||
|
if (el) {
|
||||||
|
el.focus()
|
||||||
|
el.style.height = '42px'
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 180) + 'px'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const runSlash = (c: SlashCommand) => {
|
||||||
|
setText('')
|
||||||
|
setSlashOpen(false)
|
||||||
|
resetHeight()
|
||||||
|
if (c.action === 'new') onNewChat()
|
||||||
|
else if (c.action === 'clear') onClearContext()
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
const trimmed = text.trim()
|
const trimmed = text.trim()
|
||||||
if (!trimmed && attachments.length === 0) return
|
if (!trimmed && attachments.length === 0) return
|
||||||
if (disabled) return
|
if (isStreaming) return
|
||||||
|
|
||||||
onSend(trimmed, attachments)
|
onSend(trimmed, attachments)
|
||||||
setText('')
|
setText('')
|
||||||
setAttachments([])
|
setAttachments([])
|
||||||
|
setSlashOpen(false)
|
||||||
if (textareaRef.current) {
|
resetHeight()
|
||||||
textareaRef.current.style.height = '42px'
|
}, [text, attachments, isStreaming, onSend])
|
||||||
}
|
|
||||||
}, [text, attachments, disabled, onSend])
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
|
// Slash menu navigation
|
||||||
|
if (slashOpen && filtered.length > 0) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
setSlashIndex((i) => (i + 1) % filtered.length)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
setSlashIndex((i) => (i - 1 + filtered.length) % filtered.length)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
runSlash(filtered[slashIndex])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setSlashOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Don't submit while IME is composing (Chinese input)
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !composingRef.current) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleSubmit()
|
handleSubmit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setText(e.target.value)
|
const v = e.target.value
|
||||||
|
setText(v)
|
||||||
const el = e.target
|
const el = e.target
|
||||||
el.style.height = '42px'
|
el.style.height = '42px'
|
||||||
el.style.height = Math.min(el.scrollHeight, 180) + 'px'
|
el.style.height = Math.min(el.scrollHeight, 180) + 'px'
|
||||||
|
// open slash menu when the input starts with "/" and has no space
|
||||||
|
setSlashOpen(v.startsWith('/') && !v.includes(' '))
|
||||||
|
setSlashIndex(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const uploadFiles = async (files: File[]) => {
|
||||||
const files = e.target.files
|
if (!files.length) return
|
||||||
if (!files || files.length === 0) return
|
|
||||||
|
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
try {
|
try {
|
||||||
for (const file of Array.from(files)) {
|
for (const file of files) {
|
||||||
const result = await apiClient.uploadFile(file, sessionId)
|
const result = await apiClient.uploadFile(file, sessionId)
|
||||||
if (result.status === 'success') {
|
if (result.status === 'success') {
|
||||||
setAttachments((prev) => [
|
setAttachments((prev) => [
|
||||||
@@ -59,7 +131,7 @@ const ChatInput: React.FC<ChatInputProps> = ({ onSend, onNewChat, disabled, sess
|
|||||||
{
|
{
|
||||||
file_path: result.file_path,
|
file_path: result.file_path,
|
||||||
file_name: result.file_name,
|
file_name: result.file_name,
|
||||||
file_type: result.file_type as 'image' | 'video' | 'file',
|
file_type: result.file_type as Attachment['file_type'],
|
||||||
preview_url: result.preview_url,
|
preview_url: result.preview_url,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -69,7 +141,35 @@ const ChatInput: React.FC<ChatInputProps> = ({ onSend, onNewChat, disabled, sess
|
|||||||
console.error('Upload failed:', err)
|
console.error('Upload failed:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files
|
||||||
|
if (files) await uploadFiles(Array.from(files))
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOver(false)
|
||||||
|
const files = Array.from(e.dataTransfer.files || [])
|
||||||
|
if (files.length) uploadFiles(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaste = (e: React.ClipboardEvent) => {
|
||||||
|
const items = e.clipboardData?.items
|
||||||
|
if (!items) return
|
||||||
|
const files: File[] = []
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.kind === 'file') {
|
||||||
|
const f = item.getAsFile()
|
||||||
|
if (f) files.push(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (files.length) {
|
||||||
|
e.preventDefault()
|
||||||
|
uploadFiles(files)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,9 +177,51 @@ const ChatInput: React.FC<ChatInputProps> = ({ onSend, onNewChat, disabled, sess
|
|||||||
setAttachments((prev) => prev.filter((_, i) => i !== index))
|
setAttachments((prev) => prev.filter((_, i) => i !== index))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// keep slash index in range
|
||||||
|
useEffect(() => {
|
||||||
|
if (slashIndex >= filtered.length) setSlashIndex(0)
|
||||||
|
}, [filtered.length, slashIndex])
|
||||||
|
|
||||||
|
const canSend = !isStreaming && (!!text.trim() || attachments.length > 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-shrink-0 border-t border-slate-200 dark:border-white/10 bg-white dark:bg-[#1A1A1A] px-4 py-3">
|
<div className="flex-shrink-0 border-t border-default bg-surface px-4 py-3">
|
||||||
<div className="max-w-3xl mx-auto">
|
<div
|
||||||
|
className={`max-w-3xl mx-auto relative rounded-2xl transition-all ${
|
||||||
|
dragOver ? 'ring-2 ring-accent ring-offset-2 ring-offset-surface' : ''
|
||||||
|
}`}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOver(true)
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{dragOver && (
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-2xl bg-accent-soft text-accent text-sm font-medium pointer-events-none">
|
||||||
|
{t('input_placeholder')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Slash command menu */}
|
||||||
|
{slashOpen && filtered.length > 0 && (
|
||||||
|
<div className="absolute bottom-full left-0 mb-2 w-64 rounded-xl border border-default bg-elevated shadow-lg overflow-hidden z-30">
|
||||||
|
{filtered.map((c, i) => (
|
||||||
|
<button
|
||||||
|
key={c.cmd}
|
||||||
|
onMouseEnter={() => setSlashIndex(i)}
|
||||||
|
onClick={() => runSlash(c)}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2 text-left cursor-pointer transition-colors ${
|
||||||
|
i === slashIndex ? 'bg-accent-soft' : 'hover:bg-surface-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-accent">{c.cmd}</span>
|
||||||
|
<span className="text-xs text-content-tertiary">{c.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Attachment preview */}
|
{/* Attachment preview */}
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
@@ -87,20 +229,27 @@ const ChatInput: React.FC<ChatInputProps> = ({ onSend, onNewChat, disabled, sess
|
|||||||
<div key={i} className="relative">
|
<div key={i} className="relative">
|
||||||
{att.file_type === 'image' && att.preview_url ? (
|
{att.file_type === 'image' && att.preview_url ? (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<img src={apiClient.getFileUrl(att.preview_url)} alt={att.file_name}
|
<img
|
||||||
className="w-16 h-16 rounded-lg object-cover border border-slate-200 dark:border-white/10" />
|
src={apiClient.getFileUrl(att.preview_url)}
|
||||||
<button onClick={() => removeAttachment(i)}
|
alt={att.file_name}
|
||||||
className="absolute -top-1 -right-1 w-[18px] h-[18px] rounded-full bg-red-500 text-white flex items-center justify-center cursor-pointer">
|
className="w-16 h-16 rounded-lg object-cover border border-default"
|
||||||
<i className="fas fa-times text-[8px]" />
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => removeAttachment(i)}
|
||||||
|
className="absolute -top-1 -right-1 w-[18px] h-[18px] rounded-full bg-danger text-white flex items-center justify-center cursor-pointer"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-slate-100 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-lg text-xs text-slate-500 dark:text-slate-400 max-w-[180px] relative pr-7">
|
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-inset border border-default rounded-lg text-xs text-content-secondary max-w-[180px] relative pr-7">
|
||||||
<i className="fas fa-file text-[10px]" />
|
<FileIcon size={12} />
|
||||||
<span className="truncate">{att.file_name}</span>
|
<span className="truncate">{att.file_name}</span>
|
||||||
<button onClick={() => removeAttachment(i)}
|
<button
|
||||||
className="absolute -top-1 -right-1 w-[18px] h-[18px] rounded-full bg-red-500 text-white flex items-center justify-center cursor-pointer">
|
onClick={() => removeAttachment(i)}
|
||||||
<i className="fas fa-times text-[8px]" />
|
className="absolute -top-1 -right-1 w-[18px] h-[18px] rounded-full bg-danger text-white flex items-center justify-center cursor-pointer"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -109,26 +258,32 @@ const ChatInput: React.FC<ChatInputProps> = ({ onSend, onNewChat, disabled, sess
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<div className="flex items-center flex-shrink-0">
|
<div className="flex items-center flex-shrink-0 gap-0.5 pb-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={onNewChat}
|
onClick={onNewChat}
|
||||||
className="w-9 h-10 flex items-center justify-center rounded-lg text-slate-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 cursor-pointer transition-colors duration-150"
|
className="w-9 h-9 flex items-center justify-center rounded-btn text-content-secondary hover:text-accent hover:bg-accent-soft cursor-pointer transition-colors"
|
||||||
title="New Chat"
|
title={t('session_new')}
|
||||||
>
|
>
|
||||||
<i className="fas fa-plus text-base" />
|
<Plus size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
className="w-9 h-10 flex items-center justify-center rounded-lg text-slate-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 cursor-pointer transition-colors duration-150"
|
className="w-9 h-9 flex items-center justify-center rounded-btn text-content-secondary hover:text-accent hover:bg-accent-soft cursor-pointer transition-colors disabled:opacity-50"
|
||||||
title="Attach file"
|
title={t('chat_attach')}
|
||||||
>
|
>
|
||||||
<i className="fas fa-paperclip text-base" />
|
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Paperclip size={18} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input ref={fileInputRef} type="file" className="hidden" multiple onChange={handleFileSelect}
|
<input
|
||||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.json,.xml,.zip,.py,.js,.ts,.java,.c,.cpp,.go,.rs,.md" />
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
multiple
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.json,.xml,.zip,.py,.js,.ts,.java,.c,.cpp,.go,.rs,.md"
|
||||||
|
/>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
@@ -136,23 +291,36 @@ const ChatInput: React.FC<ChatInputProps> = ({ onSend, onNewChat, disabled, sess
|
|||||||
value={text}
|
value={text}
|
||||||
onChange={handleTextChange}
|
onChange={handleTextChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
onCompositionStart={() => (composingRef.current = true)}
|
||||||
|
onCompositionEnd={() => (composingRef.current = false)}
|
||||||
placeholder={t('input_placeholder')}
|
placeholder={t('input_placeholder')}
|
||||||
disabled={disabled}
|
|
||||||
rows={1}
|
rows={1}
|
||||||
className="flex-1 min-w-0 px-4 py-[10px] rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-slate-800 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500 focus:outline-none focus:ring-0 focus:border-primary-600 text-sm leading-relaxed"
|
className="flex-1 min-w-0 px-4 py-[10px] rounded-xl border border-strong bg-inset text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent text-sm leading-relaxed transition-colors resize-none"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
{isStreaming ? (
|
||||||
onClick={handleSubmit}
|
<button
|
||||||
disabled={disabled || (!text.trim() && attachments.length === 0)}
|
onClick={onStop}
|
||||||
className="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg bg-primary-400 text-white hover:bg-primary-500 disabled:bg-slate-300 dark:disabled:bg-slate-600 disabled:cursor-not-allowed cursor-pointer transition-colors duration-150"
|
className="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-btn bg-surface-2 text-content hover:bg-inset cursor-pointer transition-colors"
|
||||||
>
|
title={t('msg_stop')}
|
||||||
<i className="fas fa-paper-plane text-sm" />
|
>
|
||||||
</button>
|
<Square size={15} className="fill-current" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSend}
|
||||||
|
className="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"
|
||||||
|
title={t('chat_send')}
|
||||||
|
>
|
||||||
|
<Send size={17} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
export default ChatInput
|
export default ChatInput
|
||||||
|
|||||||
109
desktop/src/renderer/src/components/Markdown.tsx
Normal file
109
desktop/src/renderer/src/components/Markdown.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import React, { useMemo, useRef, useCallback } from 'react'
|
||||||
|
import MarkdownIt from 'markdown-it'
|
||||||
|
import hljs from 'highlight.js'
|
||||||
|
import { t } from '../i18n'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown renderer aligned 1:1 with the web console (markdown-it + highlight.js
|
||||||
|
* + GitHub themes). Using the same engine guarantees identical line-break,
|
||||||
|
* linkify and code-highlight behavior across web and desktop.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const md: MarkdownIt = new MarkdownIt({
|
||||||
|
html: false,
|
||||||
|
breaks: true,
|
||||||
|
linkify: true,
|
||||||
|
typographer: true,
|
||||||
|
highlight(str, lang) {
|
||||||
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
|
try {
|
||||||
|
return hljs.highlight(str, { language: lang }).value
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return hljs.highlightAuto(str).value
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Open links in a new tab safely.
|
||||||
|
const defaultLinkOpen =
|
||||||
|
md.renderer.rules.link_open ||
|
||||||
|
function (tokens, idx, options, _env, self) {
|
||||||
|
return self.renderToken(tokens, idx, options)
|
||||||
|
}
|
||||||
|
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
|
||||||
|
tokens[idx].attrPush(['target', '_blank'])
|
||||||
|
tokens[idx].attrPush(['rel', 'noopener noreferrer'])
|
||||||
|
return defaultLinkOpen(tokens, idx, options, env, self)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap fenced code blocks so we can render a header (lang + copy button).
|
||||||
|
const defaultFence =
|
||||||
|
md.renderer.rules.fence ||
|
||||||
|
function (tokens, idx, options, _env, self) {
|
||||||
|
return self.renderToken(tokens, idx, options)
|
||||||
|
}
|
||||||
|
md.renderer.rules.fence = function (tokens, idx, options, env, self) {
|
||||||
|
const token = tokens[idx]
|
||||||
|
const info = token.info ? token.info.trim().split(/\s+/)[0] : ''
|
||||||
|
// Ensure the `hljs` class is present so the GitHub theme background/base
|
||||||
|
// color applies (markdown-it only adds language-* by default).
|
||||||
|
let rendered = defaultFence(tokens, idx, options, env, self)
|
||||||
|
if (rendered.includes('<code class="')) {
|
||||||
|
rendered = rendered.replace('<code class="', '<code class="hljs ')
|
||||||
|
} else {
|
||||||
|
rendered = rendered.replace('<code>', '<code class="hljs">')
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
`<div class="code-block-wrapper">` +
|
||||||
|
`<div class="code-block-header">` +
|
||||||
|
`<span class="code-block-lang">${info || 'text'}</span>` +
|
||||||
|
`<button type="button" class="code-copy-btn" data-code-id="cb-${idx}" aria-label="Copy code">${t('msg_copy')}</button>` +
|
||||||
|
`</div>` +
|
||||||
|
rendered +
|
||||||
|
`</div>`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkdownProps {
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Markdown: React.FC<MarkdownProps> = ({ content }) => {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const html = useMemo(() => md.render(content || ''), [content])
|
||||||
|
|
||||||
|
// Delegate copy clicks on code blocks (buttons are injected as raw HTML).
|
||||||
|
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
const btn = target.closest('.code-copy-btn') as HTMLElement | null
|
||||||
|
if (!btn) return
|
||||||
|
const pre = btn.closest('.code-block-wrapper')?.querySelector('pre')
|
||||||
|
if (!pre) return
|
||||||
|
navigator.clipboard.writeText(pre.textContent || '')
|
||||||
|
const original = btn.textContent
|
||||||
|
btn.textContent = t('msg_copied')
|
||||||
|
btn.classList.add('copied')
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = original
|
||||||
|
btn.classList.remove('copied')
|
||||||
|
}, 1600)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className="msg-content text-sm text-content leading-relaxed break-words"
|
||||||
|
onClick={handleClick}
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Markdown
|
||||||
@@ -1,157 +1,153 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import { Copy, Check, RefreshCw, Pencil, Trash2, File as FileIcon, Sprout } from 'lucide-react'
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
import type { ChatMessage } from '../types'
|
||||||
import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
import { t } from '../i18n'
|
||||||
import type { ChatMessage, ToolCall } from '../types'
|
import apiClient from '../api/client'
|
||||||
|
import Markdown from './Markdown'
|
||||||
|
import MessageSteps, { ThinkingStep } from './MessageSteps'
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: ChatMessage
|
message: ChatMessage
|
||||||
theme: 'light' | 'dark'
|
onRegenerate?: (id: string) => void
|
||||||
baseUrl: string
|
onEdit?: (id: string) => void
|
||||||
|
onDelete?: (msg: ChatMessage) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToolStep: React.FC<{ tool: ToolCall; theme: 'light' | 'dark' }> = ({ tool, theme }) => {
|
function fmtTime(ts: number): string {
|
||||||
const [expanded, setExpanded] = useState(false)
|
if (!ts) return ''
|
||||||
const isRunning = tool.type === 'tool_start' && !tool.status
|
const d = new Date(ts * 1000)
|
||||||
const isSuccess = tool.status === 'success'
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
|
||||||
const iconClass = isRunning
|
|
||||||
? 'fas fa-cog fa-spin text-slate-400'
|
|
||||||
: isSuccess
|
|
||||||
? 'fas fa-check text-primary-400'
|
|
||||||
: 'fas fa-times text-red-400'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border-b border-slate-100 dark:border-white/5 last:border-0">
|
|
||||||
<div
|
|
||||||
className="tool-header flex items-center gap-2 px-3 py-2"
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
>
|
|
||||||
<i className={`${iconClass} text-xs w-4 text-center`} />
|
|
||||||
<span className={`text-sm font-medium flex-1 ${tool.status === 'failed' ? 'text-red-400' : 'text-slate-700 dark:text-slate-300'}`}>
|
|
||||||
{tool.tool}
|
|
||||||
</span>
|
|
||||||
{tool.execution_time !== undefined && (
|
|
||||||
<span className="text-xs text-slate-400">{tool.execution_time.toFixed(1)}s</span>
|
|
||||||
)}
|
|
||||||
<i className={`fas fa-chevron-right text-[10px] text-slate-400 transition-transform duration-200 ${expanded ? 'rotate-90' : ''}`} />
|
|
||||||
</div>
|
|
||||||
{expanded && (
|
|
||||||
<div className="tool-detail rounded-lg mx-3 mb-3 p-3 space-y-2">
|
|
||||||
{tool.arguments && (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-semibold text-slate-500 dark:text-slate-400 mb-1">Input</div>
|
|
||||||
<pre className="text-xs overflow-x-auto whitespace-pre-wrap max-h-[200px] overflow-y-auto text-slate-600 dark:text-slate-400">
|
|
||||||
{JSON.stringify(tool.arguments, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{tool.result && (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-semibold text-slate-500 dark:text-slate-400 mb-1">Output</div>
|
|
||||||
<pre className="text-xs overflow-x-auto whitespace-pre-wrap max-h-[200px] overflow-y-auto text-slate-600 dark:text-slate-400">
|
|
||||||
{tool.result.length > 1000 ? tool.result.slice(0, 1000) + '...' : tool.result}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MessageBubble: React.FC<MessageBubbleProps> = ({ message, theme, baseUrl }) => {
|
const HoverAction: React.FC<{ onClick: () => void; title: string; danger?: boolean; children: React.ReactNode }> = ({
|
||||||
|
onClick,
|
||||||
|
title,
|
||||||
|
danger,
|
||||||
|
children,
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
className={`inline-flex items-center justify-center w-7 h-7 rounded-md cursor-pointer transition-colors text-content-tertiary ${
|
||||||
|
danger ? 'hover:text-danger hover:bg-danger-soft' : 'hover:text-content hover:bg-surface-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, onEdit, onDelete }) => {
|
||||||
const isUser = message.role === 'user'
|
const isUser = message.role === 'user'
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
const handleCopy = (text: string, id: string) => {
|
const copy = () => {
|
||||||
navigator.clipboard.writeText(text)
|
navigator.clipboard.writeText(message.content)
|
||||||
setCopiedId(id)
|
setCopied(true)
|
||||||
setTimeout(() => setCopiedId(null), 2000)
|
setTimeout(() => setCopied(false), 1800)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isUser) {
|
if (isUser) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end px-4 sm:px-6 py-3">
|
<div className="group flex flex-col items-end px-4 sm:px-6 py-2">
|
||||||
<div className="max-w-[75%] sm:max-w-[60%]">
|
{message.attachments && message.attachments.length > 0 && (
|
||||||
{/* User attachments */}
|
<div className="flex flex-wrap gap-2 mb-1.5 justify-end max-w-[75%]">
|
||||||
{message.attachments && message.attachments.length > 0 && (
|
{message.attachments.map((att, i) =>
|
||||||
<div className="flex flex-wrap gap-2 mb-2 justify-end">
|
att.file_type === 'image' && att.preview_url ? (
|
||||||
{message.attachments.map((att, i) => (
|
<img
|
||||||
<div key={i}>
|
key={i}
|
||||||
{att.file_type === 'image' && att.preview_url ? (
|
src={apiClient.getFileUrl(att.preview_url)}
|
||||||
<img src={`${baseUrl}${att.preview_url}`} alt={att.file_name}
|
alt={att.file_name}
|
||||||
className="max-w-[200px] max-h-[160px] rounded-lg object-cover" />
|
className="max-w-[180px] max-h-[150px] rounded-xl object-cover border border-default"
|
||||||
) : (
|
/>
|
||||||
<div className="px-3 py-2 bg-slate-100 dark:bg-white/5 rounded-lg text-sm text-slate-500">
|
) : (
|
||||||
<i className="fas fa-file text-xs mr-1.5" />{att.file_name}
|
<div key={i} className="flex items-center gap-1.5 px-3 py-2 bg-surface-2 rounded-xl text-xs text-content-secondary">
|
||||||
</div>
|
<FileIcon size={13} />
|
||||||
)}
|
{att.file_name}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
<div className="bg-primary-400 text-white rounded-2xl px-4 py-2.5">
|
|
||||||
<div className="msg-content text-sm whitespace-pre-wrap">{message.content}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="max-w-[75%] rounded-2xl rounded-br-md px-4 py-2.5 bg-[var(--user-bubble-bg)] text-content">
|
||||||
|
<div className="text-sm whitespace-pre-wrap break-words">{message.content}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-0.5 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<span className="text-[11px] text-content-tertiary mr-1">{fmtTime(message.timestamp)}</span>
|
||||||
|
{onEdit && message.userSeq != null && (
|
||||||
|
<HoverAction onClick={() => onEdit(message.id)} title={t('msg_edit')}>
|
||||||
|
<Pencil size={13} />
|
||||||
|
</HoverAction>
|
||||||
|
)}
|
||||||
|
{onDelete && message.userSeq != null && (
|
||||||
|
<HoverAction onClick={() => onDelete(message)} title={t('msg_delete')} danger>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</HoverAction>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assistant message
|
// Assistant
|
||||||
|
const showCursor = message.isStreaming && !message.content && (!message.steps || message.steps.length === 0)
|
||||||
|
|
||||||
|
const hasSteps = !!(message.steps && message.steps.length > 0)
|
||||||
|
const hasLiveReasoning = !!(message.reasoning && message.isStreaming)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3 px-4 sm:px-6 py-3">
|
<div className="group flex gap-3 px-4 sm:px-6 py-2">
|
||||||
<img src="./logo.jpg" alt="CowAgent" className="w-8 h-8 rounded-lg flex-shrink-0 mt-0.5" />
|
<img src="./logo.jpg" alt="CowAgent" className="w-7 h-7 rounded-lg flex-shrink-0 mt-1" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0 max-w-[calc(100%-2.5rem)]">
|
||||||
<div className="bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-2xl overflow-hidden">
|
<div className="inline-block w-full rounded-2xl border border-default bg-surface px-4 py-3">
|
||||||
{/* Agent tool steps */}
|
{message.kind === 'evolution' && (
|
||||||
{message.toolCalls && message.toolCalls.length > 0 && (
|
<div className="inline-flex items-center gap-1 mb-1.5 text-[11px] text-content-tertiary">
|
||||||
<div className="border-b border-slate-200 dark:border-white/8">
|
<Sprout size={11} />
|
||||||
{message.toolCalls.map((tool, i) => (
|
{t('msg_self_learned')}
|
||||||
<ToolStep key={i} tool={tool} theme={theme} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Answer content */}
|
{/* Steps area (thinking / tools / intermediate content), web-aligned:
|
||||||
<div className="px-4 py-3 msg-content text-sm text-slate-800 dark:text-slate-200">
|
muted, separated from the final answer by a dashed divider. */}
|
||||||
<ReactMarkdown
|
{(hasSteps || hasLiveReasoning) && (
|
||||||
components={{
|
<div className="mb-2.5 pb-2 border-b border-dashed border-default">
|
||||||
code({ className, children, ...props }) {
|
{hasLiveReasoning && <ThinkingStep content={message.reasoning!} streaming />}
|
||||||
const match = /language-(\w+)/.exec(className || '')
|
{hasSteps && <MessageSteps steps={message.steps!} />}
|
||||||
const codeStr = String(children).replace(/\n$/, '')
|
</div>
|
||||||
if (match) {
|
)}
|
||||||
const codeId = `code-${message.id}-${match[1]}-${codeStr.length}`
|
|
||||||
return (
|
{/* Final answer */}
|
||||||
<div className="relative group">
|
{message.content && <Markdown content={message.content} />}
|
||||||
<button
|
|
||||||
onClick={() => handleCopy(codeStr, codeId)}
|
{showCursor && (
|
||||||
className="absolute top-2 right-2 p-1.5 rounded-md bg-slate-700/50 hover:bg-slate-700/80 text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
<div className="flex items-center gap-1 py-0.5">
|
||||||
>
|
<span className="typing-dot" />
|
||||||
<i className={`fas ${copiedId === codeId ? 'fa-check' : 'fa-copy'} text-xs`} />
|
<span className="typing-dot" />
|
||||||
</button>
|
<span className="typing-dot" />
|
||||||
<SyntaxHighlighter
|
</div>
|
||||||
style={theme === 'dark' ? oneDark : oneLight}
|
)}
|
||||||
language={match[1]}
|
|
||||||
PreTag="div"
|
{message.isStreaming && message.content && (
|
||||||
>
|
<span className="inline-block w-[6px] h-[14px] bg-accent ml-0.5 align-middle animate-blink" />
|
||||||
{codeStr}
|
)}
|
||||||
</SyntaxHighlighter>
|
|
||||||
</div>
|
{message.isCancelled && <div className="text-xs text-warning mt-1">{t('msg_cancelled')}</div>}
|
||||||
)
|
{message.error && <div className="text-xs text-danger mt-1">{message.error}</div>}
|
||||||
}
|
</div>
|
||||||
return <code {...props}>{children}</code>
|
|
||||||
},
|
{/* Hover actions (only when finished) */}
|
||||||
}}
|
{!message.isStreaming && (message.content || message.error) && (
|
||||||
>
|
<div className="flex items-center gap-0.5 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
{message.content}
|
<span className="text-[11px] text-content-tertiary mr-1">{fmtTime(message.timestamp)}</span>
|
||||||
</ReactMarkdown>
|
<HoverAction onClick={copy} title={t('msg_copy')}>
|
||||||
{message.isStreaming && (
|
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||||
<span className="inline-block w-[6px] h-[14px] bg-primary-400 ml-0.5 animate-blink" />
|
</HoverAction>
|
||||||
|
{onRegenerate && (
|
||||||
|
<HoverAction onClick={() => onRegenerate(message.id)} title={t('msg_regenerate')}>
|
||||||
|
<RefreshCw size={13} />
|
||||||
|
</HoverAction>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
110
desktop/src/renderer/src/components/MessageSteps.tsx
Normal file
110
desktop/src/renderer/src/components/MessageSteps.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { ChevronRight, Loader2, Check, X, Brain, Wrench } from 'lucide-react'
|
||||||
|
import type { MessageStep } from '../types'
|
||||||
|
import Markdown from './Markdown'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assistant reasoning / tool steps, styled to match the web console: small,
|
||||||
|
* muted, collapsible rows with an indented detail panel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ThinkingStep: React.FC<{ content: string; streaming?: boolean }> = ({ content, streaming }) => {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-content-tertiary mb-1 last:mb-0">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 cursor-pointer hover:text-content-secondary select-none transition-colors"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
<Brain size={12} className="flex-shrink-0" />
|
||||||
|
<span className="flex-1">{streaming ? 'Thinking…' : 'Thought for a moment'}</span>
|
||||||
|
<ChevronRight size={11} className={`transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<pre className="mt-1.5 ml-4 p-2 rounded-md bg-inset border border-subtle whitespace-pre-wrap leading-relaxed max-h-[260px] overflow-y-auto font-sans text-content-tertiary">
|
||||||
|
{content}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToolStep: React.FC<{ step: MessageStep }> = ({ step }) => {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const running = step.status === 'running'
|
||||||
|
const isError = step.is_error || (!!step.status && step.status !== 'success' && !running)
|
||||||
|
|
||||||
|
const icon = running ? (
|
||||||
|
<Loader2 size={12} className="text-accent animate-spin" />
|
||||||
|
) : isError ? (
|
||||||
|
<X size={12} className="text-danger" />
|
||||||
|
) : (
|
||||||
|
<Check size={12} className="text-accent" />
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-content-tertiary mb-1 last:mb-0">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 cursor-pointer hover:text-content-secondary select-none transition-colors"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
<span className="flex-shrink-0">{icon}</span>
|
||||||
|
<Wrench size={11} className="flex-shrink-0 opacity-70" />
|
||||||
|
<span className={`font-medium ${isError ? 'text-danger' : ''}`}>{step.name}</span>
|
||||||
|
{step.execution_time !== undefined && (
|
||||||
|
<span className="opacity-60">{step.execution_time}s</span>
|
||||||
|
)}
|
||||||
|
<ChevronRight size={11} className={`ml-auto transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<div className="mt-1.5 ml-4 p-2 rounded-md bg-inset border border-subtle space-y-2">
|
||||||
|
{step.arguments && Object.keys(step.arguments).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-wide opacity-60 mb-1">Input</div>
|
||||||
|
<pre className="font-mono text-[11px] whitespace-pre-wrap break-all max-h-[200px] overflow-y-auto leading-relaxed">
|
||||||
|
{JSON.stringify(step.arguments, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{step.result && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-wide opacity-60 mb-1">
|
||||||
|
{isError ? 'Error' : 'Output'}
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
className={`font-mono text-[11px] whitespace-pre-wrap break-all max-h-[240px] overflow-y-auto leading-relaxed ${
|
||||||
|
isError ? 'text-danger' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step.result.length > 4000 ? step.result.slice(0, 4000) + '\n… (truncated)' : step.result}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders an ordered list of assistant steps (thinking / content / tool). */
|
||||||
|
const MessageSteps: React.FC<{ steps: MessageStep[] }> = ({ steps }) => {
|
||||||
|
if (!steps.length) return null
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{steps.map((step, i) => {
|
||||||
|
if (step.type === 'thinking') return <ThinkingStep key={i} content={step.content || ''} />
|
||||||
|
if (step.type === 'tool') return <ToolStep key={i} step={step} />
|
||||||
|
if (step.type === 'content' && step.content)
|
||||||
|
return (
|
||||||
|
<div key={i} className="mb-2 pb-2 border-b border-dashed border-default last:border-0 last:mb-0 last:pb-0">
|
||||||
|
<Markdown content={step.content} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ThinkingStep, ToolStep }
|
||||||
|
export default MessageSteps
|
||||||
@@ -11,6 +11,34 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
menu_channels: '通道',
|
menu_channels: '通道',
|
||||||
menu_tasks: '定时',
|
menu_tasks: '定时',
|
||||||
menu_logs: '日志',
|
menu_logs: '日志',
|
||||||
|
menu_models: '模型',
|
||||||
|
menu_knowledge: '知识库',
|
||||||
|
menu_settings: '设置',
|
||||||
|
nav_expand: '展开侧栏',
|
||||||
|
nav_collapse: '收起侧栏',
|
||||||
|
sessions_title: '会话',
|
||||||
|
session_new: '新对话',
|
||||||
|
session_rename: '重命名',
|
||||||
|
session_delete: '删除',
|
||||||
|
session_empty: '暂无会话',
|
||||||
|
session_today: '今天',
|
||||||
|
session_yesterday: '昨天',
|
||||||
|
session_earlier: '更早',
|
||||||
|
msg_copy: '复制',
|
||||||
|
msg_copied: '已复制',
|
||||||
|
msg_regenerate: '重新生成',
|
||||||
|
msg_edit: '编辑',
|
||||||
|
msg_delete: '删除',
|
||||||
|
msg_cancelled: '已中止',
|
||||||
|
msg_self_learned: '自主学习',
|
||||||
|
msg_stop: '停止',
|
||||||
|
chat_clear_context: '清除上下文',
|
||||||
|
chat_load_earlier: '加载更早的消息',
|
||||||
|
chat_send: '发送',
|
||||||
|
chat_attach: '添加附件',
|
||||||
|
slash_hint: '输入 / 查看命令',
|
||||||
|
chat_welcome: '有什么可以帮你的?',
|
||||||
|
chat_empty_hint: '发送一条消息开始对话',
|
||||||
welcome_subtitle: '我可以帮你解答问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
welcome_subtitle: '我可以帮你解答问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
||||||
example_sys_title: '系统管理',
|
example_sys_title: '系统管理',
|
||||||
example_sys_text: '帮我查看工作空间里有哪些文件',
|
example_sys_text: '帮我查看工作空间里有哪些文件',
|
||||||
@@ -85,6 +113,34 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
menu_channels: 'Channels',
|
menu_channels: 'Channels',
|
||||||
menu_tasks: 'Tasks',
|
menu_tasks: 'Tasks',
|
||||||
menu_logs: 'Logs',
|
menu_logs: 'Logs',
|
||||||
|
menu_models: 'Models',
|
||||||
|
menu_knowledge: 'Knowledge',
|
||||||
|
menu_settings: 'Settings',
|
||||||
|
nav_expand: 'Expand sidebar',
|
||||||
|
nav_collapse: 'Collapse sidebar',
|
||||||
|
sessions_title: 'Chats',
|
||||||
|
session_new: 'New chat',
|
||||||
|
session_rename: 'Rename',
|
||||||
|
session_delete: 'Delete',
|
||||||
|
session_empty: 'No conversations yet',
|
||||||
|
session_today: 'Today',
|
||||||
|
session_yesterday: 'Yesterday',
|
||||||
|
session_earlier: 'Earlier',
|
||||||
|
msg_copy: 'Copy',
|
||||||
|
msg_copied: 'Copied',
|
||||||
|
msg_regenerate: 'Regenerate',
|
||||||
|
msg_edit: 'Edit',
|
||||||
|
msg_delete: 'Delete',
|
||||||
|
msg_cancelled: 'Cancelled',
|
||||||
|
msg_self_learned: 'Self-learned',
|
||||||
|
msg_stop: 'Stop',
|
||||||
|
chat_clear_context: 'Clear context',
|
||||||
|
chat_load_earlier: 'Load earlier messages',
|
||||||
|
chat_send: 'Send',
|
||||||
|
chat_attach: 'Attach file',
|
||||||
|
slash_hint: 'Type / for commands',
|
||||||
|
chat_welcome: 'How can I help you?',
|
||||||
|
chat_empty_hint: 'Send a message to start the conversation',
|
||||||
welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills,\nand keep growing through long-term memory.',
|
welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills,\nand keep growing through long-term memory.',
|
||||||
example_sys_title: 'System',
|
example_sys_title: 'System',
|
||||||
example_sys_text: 'Show me the files in the workspace',
|
example_sys_text: 'Show me the files in the workspace',
|
||||||
|
|||||||
@@ -1,203 +1,202 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
import React, { useEffect, useRef, useCallback, useState } from 'react'
|
||||||
|
import { ChevronUp, Loader2 } from 'lucide-react'
|
||||||
import MessageBubble from '../components/MessageBubble'
|
import MessageBubble from '../components/MessageBubble'
|
||||||
import ChatInput from '../components/ChatInput'
|
import ChatInput, { type ChatInputHandle } from '../components/ChatInput'
|
||||||
import { useTheme } from '../hooks/useTheme'
|
|
||||||
import { t } from '../i18n'
|
import { t } from '../i18n'
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
import type { ChatMessage, Attachment, ToolCall } from '../types'
|
import type { Attachment, ChatMessage } from '../types'
|
||||||
|
import { useChatStore } from '../store/chatStore'
|
||||||
|
import { useSessionStore } from '../store/sessionStore'
|
||||||
|
|
||||||
interface ChatPageProps {
|
interface ChatPageProps {
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SUGGESTIONS = ['example_sys', 'example_task', 'example_code'] as const
|
||||||
|
|
||||||
const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
const activeId = useSessionStore((s) => s.activeId)
|
||||||
const [isStreaming, setIsStreaming] = useState(false)
|
const newSession = useSessionStore((s) => s.newSession)
|
||||||
const [sessionId, setSessionId] = useState(() => `session_${Date.now().toString(36)}`)
|
const loadSessions = useSessionStore((s) => s.loadSessions)
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
||||||
const { theme } = useTheme()
|
const session = useChatStore((s) => s.sessions[activeId])
|
||||||
|
const send = useChatStore((s) => s.send)
|
||||||
|
const cancel = useChatStore((s) => s.cancel)
|
||||||
|
const regenerate = useChatStore((s) => s.regenerate)
|
||||||
|
const editUserMessage = useChatStore((s) => s.editUserMessage)
|
||||||
|
const deleteMessage = useChatStore((s) => s.deleteMessage)
|
||||||
|
const loadHistory = useChatStore((s) => s.loadHistory)
|
||||||
|
const ensureSession = useChatStore((s) => s.ensureSession)
|
||||||
|
|
||||||
|
const messages = session?.messages ?? []
|
||||||
|
const isStreaming = session?.isStreaming ?? false
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
const inputResetRef = useRef<ChatInputHandle>(null)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const titlePendingRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiClient.setBaseUrl(baseUrl)
|
apiClient.setBaseUrl(baseUrl)
|
||||||
}, [baseUrl])
|
}, [baseUrl])
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
// Load history when switching to a session that hasn't been loaded yet.
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
useEffect(() => {
|
||||||
|
ensureSession(activeId)
|
||||||
|
const s = useChatStore.getState().sessions[activeId]
|
||||||
|
if (s && !s.historyLoaded && !s.isStreaming) {
|
||||||
|
loadHistory(activeId, 1)
|
||||||
|
}
|
||||||
|
}, [activeId, ensureSession, loadHistory])
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback((smooth = true) => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Auto-scroll on new content while near the bottom.
|
||||||
|
const lastLenRef = useRef(0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom()
|
const el = scrollRef.current
|
||||||
|
if (!el) return
|
||||||
|
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 160
|
||||||
|
const grew = messages.length !== lastLenRef.current
|
||||||
|
lastLenRef.current = messages.length
|
||||||
|
if (nearBottom || grew) scrollToBottom(true)
|
||||||
}, [messages, scrollToBottom])
|
}, [messages, scrollToBottom])
|
||||||
|
|
||||||
const handleNewChat = useCallback(() => {
|
const handleSend = useCallback(
|
||||||
setMessages([])
|
async (text: string, attachments: Attachment[]) => {
|
||||||
setSessionId(`session_${Date.now().toString(36)}`)
|
const sid = activeId
|
||||||
}, [])
|
const isFirst = (useChatStore.getState().sessions[sid]?.messages.length ?? 0) === 0
|
||||||
|
titlePendingRef.current = isFirst
|
||||||
const handleSend = useCallback(async (text: string, attachments: Attachment[]) => {
|
await send(sid, text, attachments)
|
||||||
const userMessage: ChatMessage = {
|
// After the first message, refresh the list and ask backend to title it.
|
||||||
id: `user_${Date.now()}`,
|
if (isFirst) {
|
||||||
role: 'user',
|
try {
|
||||||
content: text,
|
await apiClient.generateSessionTitle(sid, text)
|
||||||
timestamp: Date.now() / 1000,
|
} catch {
|
||||||
attachments: attachments.length > 0 ? attachments : undefined,
|
/* ignore */
|
||||||
}
|
|
||||||
setMessages((prev) => [...prev, userMessage])
|
|
||||||
|
|
||||||
const assistantId = `assistant_${Date.now()}`
|
|
||||||
const assistantMessage: ChatMessage = {
|
|
||||||
id: assistantId,
|
|
||||||
role: 'assistant',
|
|
||||||
content: '',
|
|
||||||
timestamp: Date.now() / 1000,
|
|
||||||
isStreaming: true,
|
|
||||||
toolCalls: [],
|
|
||||||
}
|
|
||||||
setMessages((prev) => [...prev, assistantMessage])
|
|
||||||
setIsStreaming(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await apiClient.sendMessage(
|
|
||||||
sessionId, text, true,
|
|
||||||
attachments.length > 0 ? attachments : undefined
|
|
||||||
)
|
|
||||||
|
|
||||||
if (response.status === 'success' && response.stream) {
|
|
||||||
const eventSource = apiClient.createSSEStream(response.request_id)
|
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data)
|
|
||||||
switch (data.type) {
|
|
||||||
case 'delta':
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((msg) =>
|
|
||||||
msg.id === assistantId ? { ...msg, content: msg.content + data.content } : msg
|
|
||||||
)
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case 'tool_start':
|
|
||||||
case 'tool_end': {
|
|
||||||
const toolCall: ToolCall = {
|
|
||||||
type: data.type, tool: data.tool,
|
|
||||||
arguments: data.arguments, result: data.result,
|
|
||||||
status: data.status, execution_time: data.execution_time,
|
|
||||||
}
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((msg) =>
|
|
||||||
msg.id === assistantId ? { ...msg, toolCalls: [...(msg.toolCalls || []), toolCall] } : msg
|
|
||||||
)
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'done':
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((msg) =>
|
|
||||||
msg.id === assistantId ? { ...msg, content: data.content || msg.content, isStreaming: false } : msg
|
|
||||||
)
|
|
||||||
)
|
|
||||||
setIsStreaming(false)
|
|
||||||
eventSource.close()
|
|
||||||
break
|
|
||||||
case 'error':
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((msg) =>
|
|
||||||
msg.id === assistantId ? { ...msg, content: `Error: ${data.message}`, isStreaming: false } : msg
|
|
||||||
)
|
|
||||||
)
|
|
||||||
setIsStreaming(false)
|
|
||||||
eventSource.close()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} catch { /* ignore keepalive */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
eventSource.onerror = () => {
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((msg) => (msg.id === assistantId ? { ...msg, isStreaming: false } : msg))
|
|
||||||
)
|
|
||||||
setIsStreaming(false)
|
|
||||||
eventSource.close()
|
|
||||||
}
|
}
|
||||||
|
loadSessions(1)
|
||||||
|
titlePendingRef.current = false
|
||||||
}
|
}
|
||||||
} catch (err) {
|
},
|
||||||
setMessages((prev) =>
|
[activeId, send, loadSessions]
|
||||||
prev.map((msg) =>
|
)
|
||||||
msg.id === assistantId ? { ...msg, content: `Connection error: ${err}`, isStreaming: false } : msg
|
|
||||||
)
|
const handleNewChat = useCallback(() => {
|
||||||
)
|
const id = newSession()
|
||||||
setIsStreaming(false)
|
ensureSession(id)
|
||||||
|
loadHistory(id, 1)
|
||||||
|
}, [newSession, ensureSession, loadHistory])
|
||||||
|
|
||||||
|
const handleClearContext = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await apiClient.clearContext(activeId)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
}
|
}
|
||||||
}, [sessionId])
|
}, [activeId])
|
||||||
|
|
||||||
|
const handleStop = useCallback(() => cancel(activeId), [cancel, activeId])
|
||||||
|
|
||||||
|
const handleRegenerate = useCallback((id: string) => regenerate(activeId, id), [regenerate, activeId])
|
||||||
|
|
||||||
|
const handleEdit = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
const result = editUserMessage(activeId, id)
|
||||||
|
if (result && inputResetRef.current) inputResetRef.current(result.text, result.attachments)
|
||||||
|
},
|
||||||
|
[editUserMessage, activeId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
(msg: ChatMessage) => {
|
||||||
|
if (msg.userSeq != null) deleteMessage(activeId, msg.userSeq, true)
|
||||||
|
},
|
||||||
|
[deleteMessage, activeId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleScroll = useCallback(
|
||||||
|
async (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const el = e.currentTarget
|
||||||
|
const s = useChatStore.getState().sessions[activeId]
|
||||||
|
if (el.scrollTop < 40 && s?.historyHasMore && !loadingMore && !isStreaming) {
|
||||||
|
setLoadingMore(true)
|
||||||
|
const prevHeight = el.scrollHeight
|
||||||
|
await loadHistory(activeId, s.historyPage + 1)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
// preserve scroll position after prepending older messages
|
||||||
|
el.scrollTop = el.scrollHeight - prevHeight
|
||||||
|
setLoadingMore(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeId, loadHistory, loadingMore, isStreaming]
|
||||||
|
)
|
||||||
|
|
||||||
|
const isEmpty = messages.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
{/* Messages */}
|
<div ref={scrollRef} className="flex-1 overflow-y-auto" onScroll={handleScroll}>
|
||||||
<div className="flex-1 overflow-y-auto">
|
{loadingMore && (
|
||||||
{messages.length === 0 ? (
|
<div className="flex items-center justify-center py-3 text-content-tertiary">
|
||||||
/* Welcome Screen — exact match with Web console */
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEmpty ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full px-6 py-12">
|
<div className="flex flex-col items-center justify-center h-full px-6 py-12">
|
||||||
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-6 shadow-lg shadow-primary-500/20" />
|
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-5 shadow-md" />
|
||||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-slate-100 mb-3">CowAgent</h1>
|
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
|
||||||
<p className="text-slate-500 dark:text-slate-400 text-center max-w-lg mb-10 leading-relaxed whitespace-pre-line">
|
<p className="text-content-tertiary text-sm text-center max-w-md mb-8">{t('chat_empty_hint')}</p>
|
||||||
{t('welcome_subtitle')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 w-full max-w-2xl">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 w-full max-w-2xl">
|
||||||
{/* System card */}
|
{SUGGESTIONS.map((key) => (
|
||||||
<div
|
<button
|
||||||
onClick={() => handleSend(t('example_sys_text'), [])}
|
key={key}
|
||||||
className="group bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4 cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 hover:shadow-md transition-all duration-200"
|
onClick={() => handleSend(t(`${key}_text` as Parameters<typeof t>[0]), [])}
|
||||||
>
|
className="text-left bg-surface border border-default rounded-xl p-3.5 cursor-pointer hover:border-accent hover:shadow-sm transition-all"
|
||||||
<div className="flex items-center gap-2 mb-2">
|
>
|
||||||
<div className="w-7 h-7 rounded-lg bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
|
<div className="font-medium text-sm text-content mb-1">
|
||||||
<i className="fas fa-folder-open text-blue-500 text-xs" />
|
{t(`${key}_title` as Parameters<typeof t>[0])}
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium text-sm text-slate-700 dark:text-slate-200">{t('example_sys_title')}</span>
|
<p className="text-xs text-content-tertiary leading-relaxed line-clamp-2">
|
||||||
</div>
|
{t(`${key}_text` as Parameters<typeof t>[0])}
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400 leading-relaxed">{t('example_sys_text')}</p>
|
</p>
|
||||||
</div>
|
</button>
|
||||||
|
))}
|
||||||
{/* Skills card */}
|
|
||||||
<div
|
|
||||||
onClick={() => handleSend(t('example_task_text'), [])}
|
|
||||||
className="group bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4 cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 hover:shadow-md transition-all duration-200"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<div className="w-7 h-7 rounded-lg bg-amber-50 dark:bg-amber-900/30 flex items-center justify-center">
|
|
||||||
<i className="fas fa-clock text-amber-500 text-xs" />
|
|
||||||
</div>
|
|
||||||
<span className="font-medium text-sm text-slate-700 dark:text-slate-200">{t('example_task_title')}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400 leading-relaxed">{t('example_task_text')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Coding card */}
|
|
||||||
<div
|
|
||||||
onClick={() => handleSend(t('example_code_text'), [])}
|
|
||||||
className="group bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4 cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 hover:shadow-md transition-all duration-200"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<div className="w-7 h-7 rounded-lg bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center">
|
|
||||||
<i className="fas fa-code text-emerald-500 text-xs" />
|
|
||||||
</div>
|
|
||||||
<span className="font-medium text-sm text-slate-700 dark:text-slate-200">{t('example_code_title')}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400 leading-relaxed">{t('example_code_text')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-2">
|
<div className="py-3 max-w-3xl mx-auto">
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<MessageBubble key={msg.id} message={msg} theme={theme} baseUrl={baseUrl} />
|
<MessageBubble
|
||||||
|
key={msg.id}
|
||||||
|
message={msg}
|
||||||
|
onRegenerate={handleRegenerate}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChatInput onSend={handleSend} onNewChat={handleNewChat} disabled={isStreaming} sessionId={sessionId} />
|
{/* Jump-to-bottom affordance could go here in a later pass */}
|
||||||
|
|
||||||
|
<ChatInput
|
||||||
|
onSend={handleSend}
|
||||||
|
onNewChat={handleNewChat}
|
||||||
|
onStop={handleStop}
|
||||||
|
onClearContext={handleClearContext}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
sessionId={activeId}
|
||||||
|
ref={inputResetRef}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
420
desktop/src/renderer/src/store/chatStore.ts
Normal file
420
desktop/src/renderer/src/store/chatStore.ts
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import type { ChatMessage, MessageStep, Attachment, StreamEvent, HistoryMessage } from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-session chat state. Supports parallel sessions: each session keeps its
|
||||||
|
* own message list and active stream, so switching sessions never interrupts a
|
||||||
|
* background run. The active EventSource lives in `streams` (outside React).
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface SessionRuntime {
|
||||||
|
messages: ChatMessage[]
|
||||||
|
isStreaming: boolean
|
||||||
|
requestId: string | null
|
||||||
|
// history pagination
|
||||||
|
historyPage: number
|
||||||
|
historyHasMore: boolean
|
||||||
|
historyLoaded: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatState {
|
||||||
|
sessions: Record<string, SessionRuntime>
|
||||||
|
|
||||||
|
getSession: (sid: string) => SessionRuntime
|
||||||
|
ensureSession: (sid: string) => void
|
||||||
|
|
||||||
|
send: (sid: string, text: string, attachments: Attachment[]) => Promise<void>
|
||||||
|
cancel: (sid: string) => Promise<void>
|
||||||
|
regenerate: (sid: string, botMessageId: string) => Promise<void>
|
||||||
|
editUserMessage: (sid: string, messageId: string) => { text: string; attachments: Attachment[] } | null
|
||||||
|
deleteMessage: (sid: string, userSeq: number, cascade: boolean) => Promise<void>
|
||||||
|
|
||||||
|
loadHistory: (sid: string, page?: number) => Promise<void>
|
||||||
|
clearLocal: (sid: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventSource instances kept outside the store (not serializable).
|
||||||
|
const streams: Record<string, EventSource> = {}
|
||||||
|
|
||||||
|
const EMPTY: SessionRuntime = {
|
||||||
|
messages: [],
|
||||||
|
isStreaming: false,
|
||||||
|
requestId: null,
|
||||||
|
historyPage: 0,
|
||||||
|
historyHasMore: false,
|
||||||
|
historyLoaded: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
function uid(prefix: string): string {
|
||||||
|
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* History keeps the English cancel marker for the LLM; strip it for display so
|
||||||
|
* the bubble shows a clean answer + a dedicated "cancelled" badge instead.
|
||||||
|
*/
|
||||||
|
function stripCancelMarker(text: string): string {
|
||||||
|
if (!text) return text
|
||||||
|
return text
|
||||||
|
.replace(/_\(Cancelled by user\)_/g, '')
|
||||||
|
.replace(/_\(Cancelled\)_/g, '')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a backend history message into a UI ChatMessage. */
|
||||||
|
function historyToMessage(m: HistoryMessage): ChatMessage {
|
||||||
|
if (m.role === 'user') {
|
||||||
|
return {
|
||||||
|
id: uid('user'),
|
||||||
|
role: 'user',
|
||||||
|
content: m.content,
|
||||||
|
timestamp: m.created_at,
|
||||||
|
userSeq: m._seq,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The backend stores the final answer both as `content` and as the LAST
|
||||||
|
// `content` step. Strip that trailing content step so it isn't rendered
|
||||||
|
// twice (matches the web console's renderStepsHtml logic).
|
||||||
|
const raw = m.steps || []
|
||||||
|
let lastContentIdx = -1
|
||||||
|
for (let i = raw.length - 1; i >= 0; i--) {
|
||||||
|
if (raw[i].type === 'content') {
|
||||||
|
lastContentIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const steps: MessageStep[] = raw
|
||||||
|
.filter((_, i) => i !== lastContentIdx)
|
||||||
|
.map((s) => ({ ...s }))
|
||||||
|
const finalContent = m.content || (lastContentIdx >= 0 ? raw[lastContentIdx].content || '' : '')
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: uid('assistant'),
|
||||||
|
role: 'assistant',
|
||||||
|
content: finalContent,
|
||||||
|
timestamp: m.created_at,
|
||||||
|
steps,
|
||||||
|
reasoning: m.reasoning,
|
||||||
|
kind: m.kind,
|
||||||
|
extras: m.extras,
|
||||||
|
botSeq: m._seq,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChatStore = create<ChatState>((set, get) => {
|
||||||
|
// --- helpers operating on a single session immutably ---
|
||||||
|
const patchSession = (sid: string, patch: Partial<SessionRuntime>) =>
|
||||||
|
set((st) => ({
|
||||||
|
sessions: { ...st.sessions, [sid]: { ...(st.sessions[sid] || EMPTY), ...patch } },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const patchMessages = (sid: string, fn: (msgs: ChatMessage[]) => ChatMessage[]) =>
|
||||||
|
set((st) => {
|
||||||
|
const cur = st.sessions[sid] || EMPTY
|
||||||
|
return { sessions: { ...st.sessions, [sid]: { ...cur, messages: fn(cur.messages) } } }
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMsg = (sid: string, id: string, fn: (m: ChatMessage) => ChatMessage) =>
|
||||||
|
patchMessages(sid, (msgs) => msgs.map((m) => (m.id === id ? fn(m) : m)))
|
||||||
|
|
||||||
|
/** Attach an EventSource for a request and wire all SSE events to a bot message. */
|
||||||
|
const attachStream = (sid: string, requestId: string, botId: string) => {
|
||||||
|
const es = apiClient.createSSEStream(requestId)
|
||||||
|
streams[sid] = es
|
||||||
|
let tailTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const closeStream = () => {
|
||||||
|
if (tailTimer) {
|
||||||
|
clearTimeout(tailTimer)
|
||||||
|
tailTimer = null
|
||||||
|
}
|
||||||
|
es.close()
|
||||||
|
if (streams[sid] === es) delete streams[sid]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the turn as complete: UI becomes interactive again immediately.
|
||||||
|
const completeTurn = () => {
|
||||||
|
patchSession(sid, { isStreaming: false, requestId: null })
|
||||||
|
updateMsg(sid, botId, (m) => ({ ...m, isStreaming: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishStream = () => {
|
||||||
|
completeTurn()
|
||||||
|
closeStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
es.onmessage = (event) => {
|
||||||
|
let data: StreamEvent
|
||||||
|
try {
|
||||||
|
data = JSON.parse(event.data)
|
||||||
|
} catch {
|
||||||
|
return // keepalive
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case 'reasoning':
|
||||||
|
updateMsg(sid, botId, (m) => ({ ...m, reasoning: (m.reasoning || '') + (data.content || '') }))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'delta':
|
||||||
|
updateMsg(sid, botId, (m) => ({ ...m, content: m.content + (data.content || '') }))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'message_end':
|
||||||
|
// Freeze accumulated text as a content step when tool calls follow,
|
||||||
|
// mirroring the web console's interleaved step model.
|
||||||
|
if (data.has_tool_calls) {
|
||||||
|
updateMsg(sid, botId, (m) => {
|
||||||
|
if (!m.content.trim()) return m
|
||||||
|
const steps = [...(m.steps || []), { type: 'content' as const, content: m.content.trim() }]
|
||||||
|
return { ...m, steps, content: '' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'tool_start':
|
||||||
|
updateMsg(sid, botId, (m) => {
|
||||||
|
// commit any reasoning into a thinking step
|
||||||
|
const steps = [...(m.steps || [])]
|
||||||
|
if (m.reasoning && m.reasoning.trim()) {
|
||||||
|
steps.push({ type: 'thinking', content: m.reasoning.trim() })
|
||||||
|
}
|
||||||
|
steps.push({
|
||||||
|
type: 'tool',
|
||||||
|
id: data.tool_call_id,
|
||||||
|
name: data.tool,
|
||||||
|
arguments: data.arguments,
|
||||||
|
status: 'running',
|
||||||
|
})
|
||||||
|
return { ...m, steps, reasoning: '', content: '' }
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'tool_progress':
|
||||||
|
updateMsg(sid, botId, (m) => ({
|
||||||
|
...m,
|
||||||
|
steps: (m.steps || []).map((s) =>
|
||||||
|
s.type === 'tool' && s.id === data.tool_call_id ? { ...s, result: data.content } : s
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'tool_end':
|
||||||
|
updateMsg(sid, botId, (m) => ({
|
||||||
|
...m,
|
||||||
|
steps: (m.steps || []).map((s) =>
|
||||||
|
s.type === 'tool' && s.id === data.tool_call_id
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
status: data.status,
|
||||||
|
result: data.result ?? s.result,
|
||||||
|
execution_time: data.execution_time,
|
||||||
|
is_error: data.status !== 'success',
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'cancelled':
|
||||||
|
updateMsg(sid, botId, (m) => ({ ...m, isCancelled: true }))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'done':
|
||||||
|
updateMsg(sid, botId, (m) => {
|
||||||
|
const next = stripCancelMarker(data.content || m.content)
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
content: next,
|
||||||
|
botSeq: data.bot_seq ?? m.botSeq,
|
||||||
|
isStreaming: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// backfill the preceding user message's seq for edit/delete
|
||||||
|
if (data.user_seq != null) {
|
||||||
|
patchMessages(sid, (msgs) => {
|
||||||
|
const idx = msgs.findIndex((m) => m.id === botId)
|
||||||
|
for (let i = idx - 1; i >= 0; i--) {
|
||||||
|
if (msgs[i].role === 'user') {
|
||||||
|
msgs[i] = { ...msgs[i], userSeq: data.user_seq }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...msgs]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// The answer is final: free the UI now (don't wait for onerror).
|
||||||
|
completeTurn()
|
||||||
|
// Backend keeps the stream open for a short tail (e.g. TTS audio via
|
||||||
|
// voice_attach). Close it ourselves if nothing else arrives.
|
||||||
|
if (tailTimer) clearTimeout(tailTimer)
|
||||||
|
tailTimer = setTimeout(closeStream, 1500)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'voice_attach':
|
||||||
|
if (data.audio_url) {
|
||||||
|
updateMsg(sid, botId, (m) => ({
|
||||||
|
...m,
|
||||||
|
extras: { ...(m.extras || {}), audio: data.audio_url },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
finishStream()
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
updateMsg(sid, botId, (m) => ({ ...m, error: data.message || 'stream error', isStreaming: false }))
|
||||||
|
finishStream()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
// Stream closed (often the normal end after `done`/tail). Finalize.
|
||||||
|
finishStream()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions: {},
|
||||||
|
|
||||||
|
getSession: (sid) => get().sessions[sid] || EMPTY,
|
||||||
|
|
||||||
|
ensureSession: (sid) => {
|
||||||
|
if (!get().sessions[sid]) patchSession(sid, { ...EMPTY })
|
||||||
|
},
|
||||||
|
|
||||||
|
send: async (sid, text, attachments) => {
|
||||||
|
const userMsg: ChatMessage = {
|
||||||
|
id: uid('user'),
|
||||||
|
role: 'user',
|
||||||
|
content: text,
|
||||||
|
timestamp: Date.now() / 1000,
|
||||||
|
attachments: attachments.length ? attachments : undefined,
|
||||||
|
}
|
||||||
|
const botId = uid('assistant')
|
||||||
|
const botMsg: ChatMessage = {
|
||||||
|
id: botId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
timestamp: Date.now() / 1000,
|
||||||
|
steps: [],
|
||||||
|
isStreaming: true,
|
||||||
|
}
|
||||||
|
patchMessages(sid, (msgs) => [...msgs, userMsg, botMsg])
|
||||||
|
patchSession(sid, { isStreaming: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiClient.sendMessage(sid, text, {
|
||||||
|
stream: true,
|
||||||
|
attachments: attachments.length ? attachments : undefined,
|
||||||
|
})
|
||||||
|
if (res.status === 'success' && res.stream && res.request_id) {
|
||||||
|
patchSession(sid, { requestId: res.request_id })
|
||||||
|
attachStream(sid, res.request_id, botId)
|
||||||
|
} else if (res.inline_reply) {
|
||||||
|
updateMsg(sid, botId, (m) => ({ ...m, content: res.inline_reply || '', isStreaming: false }))
|
||||||
|
patchSession(sid, { isStreaming: false })
|
||||||
|
} else {
|
||||||
|
updateMsg(sid, botId, (m) => ({ ...m, error: 'send failed', isStreaming: false }))
|
||||||
|
patchSession(sid, { isStreaming: false })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
updateMsg(sid, botId, (m) => ({ ...m, error: `${err}`, isStreaming: false }))
|
||||||
|
patchSession(sid, { isStreaming: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel: async (sid) => {
|
||||||
|
const s = get().sessions[sid]
|
||||||
|
if (!s?.requestId) return
|
||||||
|
try {
|
||||||
|
await apiClient.cancel({ requestId: s.requestId, sessionId: sid })
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
regenerate: async (sid, botMessageId) => {
|
||||||
|
const s = get().sessions[sid] || EMPTY
|
||||||
|
const idx = s.messages.findIndex((m) => m.id === botMessageId)
|
||||||
|
if (idx < 0) return
|
||||||
|
// find the user message that produced this bot reply
|
||||||
|
let userMsg: ChatMessage | null = null
|
||||||
|
for (let i = idx - 1; i >= 0; i--) {
|
||||||
|
if (s.messages[i].role === 'user') {
|
||||||
|
userMsg = s.messages[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!userMsg) return
|
||||||
|
// delete the turn on the backend (by the user's seq) then resend
|
||||||
|
if (userMsg.userSeq != null) {
|
||||||
|
try {
|
||||||
|
await apiClient.deleteMessage({ sessionId: sid, userSeq: userMsg.userSeq, deleteUser: true, cascade: true })
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// drop the user+bot messages locally from idx-? : remove from the user msg onward
|
||||||
|
const userIdx = s.messages.indexOf(userMsg)
|
||||||
|
patchMessages(sid, (msgs) => msgs.slice(0, userIdx))
|
||||||
|
await get().send(sid, userMsg.content, userMsg.attachments || [])
|
||||||
|
},
|
||||||
|
|
||||||
|
editUserMessage: (sid, messageId) => {
|
||||||
|
const s = get().sessions[sid] || EMPTY
|
||||||
|
const msg = s.messages.find((m) => m.id === messageId)
|
||||||
|
if (!msg || msg.role !== 'user') return null
|
||||||
|
const userIdx = s.messages.indexOf(msg)
|
||||||
|
// cascade-delete this turn on the backend
|
||||||
|
if (msg.userSeq != null) {
|
||||||
|
apiClient
|
||||||
|
.deleteMessage({ sessionId: sid, userSeq: msg.userSeq, deleteUser: true, cascade: true })
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
patchMessages(sid, (msgs) => msgs.slice(0, userIdx))
|
||||||
|
return { text: msg.content, attachments: msg.attachments || [] }
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteMessage: async (sid, userSeq, cascade) => {
|
||||||
|
try {
|
||||||
|
await apiClient.deleteMessage({ sessionId: sid, userSeq, deleteUser: true, cascade })
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
// reload history to reflect server state
|
||||||
|
await get().loadHistory(sid, 1)
|
||||||
|
},
|
||||||
|
|
||||||
|
loadHistory: async (sid, page = 1) => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.getHistory(sid, page, 20)
|
||||||
|
const uiMsgs = res.messages.map(historyToMessage)
|
||||||
|
patchSession(sid, {
|
||||||
|
historyPage: res.page,
|
||||||
|
historyHasMore: res.has_more,
|
||||||
|
historyLoaded: true,
|
||||||
|
})
|
||||||
|
if (page === 1) {
|
||||||
|
patchMessages(sid, () => uiMsgs)
|
||||||
|
} else {
|
||||||
|
// older page: prepend
|
||||||
|
patchMessages(sid, (msgs) => [...uiMsgs, ...msgs])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
patchSession(sid, { historyLoaded: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearLocal: (sid) => {
|
||||||
|
const es = streams[sid]
|
||||||
|
if (es) {
|
||||||
|
es.close()
|
||||||
|
delete streams[sid]
|
||||||
|
}
|
||||||
|
patchSession(sid, { ...EMPTY })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
86
desktop/src/renderer/src/store/sessionStore.ts
Normal file
86
desktop/src/renderer/src/store/sessionStore.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import type { SessionItem } from '../types'
|
||||||
|
|
||||||
|
const ACTIVE_KEY = 'cow_session_id'
|
||||||
|
|
||||||
|
interface SessionState {
|
||||||
|
sessions: SessionItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
hasMore: boolean
|
||||||
|
loading: boolean
|
||||||
|
activeId: string
|
||||||
|
|
||||||
|
loadSessions: (page?: number) => Promise<void>
|
||||||
|
loadMore: () => Promise<void>
|
||||||
|
setActive: (id: string) => void
|
||||||
|
newSession: () => string
|
||||||
|
rename: (id: string, title: string) => Promise<void>
|
||||||
|
remove: (id: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function genId(): string {
|
||||||
|
return `session_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function readActive(): string {
|
||||||
|
return localStorage.getItem(ACTIVE_KEY) || genId()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSessionStore = create<SessionState>((set, get) => ({
|
||||||
|
sessions: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
hasMore: false,
|
||||||
|
loading: false,
|
||||||
|
activeId: readActive(),
|
||||||
|
|
||||||
|
loadSessions: async (page = 1) => {
|
||||||
|
set({ loading: true })
|
||||||
|
try {
|
||||||
|
const res = await apiClient.getSessions(page, 50)
|
||||||
|
set((s) => ({
|
||||||
|
sessions: page === 1 ? res.sessions : [...s.sessions, ...res.sessions],
|
||||||
|
total: res.total,
|
||||||
|
page: res.page,
|
||||||
|
hasMore: res.has_more,
|
||||||
|
loading: false,
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
set({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadMore: async () => {
|
||||||
|
const { hasMore, loading, page } = get()
|
||||||
|
if (!hasMore || loading) return
|
||||||
|
await get().loadSessions(page + 1)
|
||||||
|
},
|
||||||
|
|
||||||
|
setActive: (id) => {
|
||||||
|
localStorage.setItem(ACTIVE_KEY, id)
|
||||||
|
set({ activeId: id })
|
||||||
|
},
|
||||||
|
|
||||||
|
newSession: () => {
|
||||||
|
const id = genId()
|
||||||
|
localStorage.setItem(ACTIVE_KEY, id)
|
||||||
|
set({ activeId: id })
|
||||||
|
return id
|
||||||
|
},
|
||||||
|
|
||||||
|
rename: async (id, title) => {
|
||||||
|
await apiClient.renameSession(id, title)
|
||||||
|
set((s) => ({
|
||||||
|
sessions: s.sessions.map((sess) => (sess.session_id === id ? { ...sess, title } : sess)),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
remove: async (id) => {
|
||||||
|
await apiClient.deleteSession(id)
|
||||||
|
set((s) => ({ sessions: s.sessions.filter((sess) => sess.session_id !== id) }))
|
||||||
|
// If we removed the active one, start a fresh session
|
||||||
|
if (get().activeId === id) get().newSession()
|
||||||
|
},
|
||||||
|
}))
|
||||||
48
desktop/src/renderer/src/store/uiStore.ts
Normal file
48
desktop/src/renderer/src/store/uiStore.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
const NAV_KEY = 'cow_nav_collapsed'
|
||||||
|
const SESSIONS_KEY = 'cow_sessions_collapsed'
|
||||||
|
|
||||||
|
interface UIState {
|
||||||
|
/** Navigation rail collapsed (icon-only) vs expanded (icon + label). */
|
||||||
|
navCollapsed: boolean
|
||||||
|
toggleNav: () => void
|
||||||
|
setNavCollapsed: (v: boolean) => void
|
||||||
|
|
||||||
|
/** Session list panel collapsed (hidden) vs expanded. */
|
||||||
|
sessionsCollapsed: boolean
|
||||||
|
toggleSessions: () => void
|
||||||
|
|
||||||
|
/** Currently active session id (Chat page). */
|
||||||
|
activeSessionId: string | null
|
||||||
|
setActiveSessionId: (id: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBool(key: string): boolean {
|
||||||
|
return localStorage.getItem(key) === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUIStore = create<UIState>((set) => ({
|
||||||
|
navCollapsed: readBool(NAV_KEY),
|
||||||
|
toggleNav: () =>
|
||||||
|
set((s) => {
|
||||||
|
const next = !s.navCollapsed
|
||||||
|
localStorage.setItem(NAV_KEY, next ? '1' : '0')
|
||||||
|
return { navCollapsed: next }
|
||||||
|
}),
|
||||||
|
setNavCollapsed: (v) => {
|
||||||
|
localStorage.setItem(NAV_KEY, v ? '1' : '0')
|
||||||
|
set({ navCollapsed: v })
|
||||||
|
},
|
||||||
|
|
||||||
|
sessionsCollapsed: readBool(SESSIONS_KEY),
|
||||||
|
toggleSessions: () =>
|
||||||
|
set((s) => {
|
||||||
|
const next = !s.sessionsCollapsed
|
||||||
|
localStorage.setItem(SESSIONS_KEY, next ? '1' : '0')
|
||||||
|
return { sessionsCollapsed: next }
|
||||||
|
}),
|
||||||
|
|
||||||
|
activeSessionId: null,
|
||||||
|
setActiveSessionId: (id) => set({ activeSessionId: id }),
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user