mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +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"
|
||||
},
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-react": "^1.21.0",
|
||||
"markdown-it": "^14.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"lucide-react": "^0.460.0"
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"concurrently": "^9.1.0",
|
||||
@@ -71,7 +72,9 @@
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": ["universal"]
|
||||
"arch": [
|
||||
"universal"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -80,7 +83,9 @@
|
||||
"target": [
|
||||
{
|
||||
"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 type { Attachment } from '../types'
|
||||
import apiClient from '../api/client'
|
||||
|
||||
export type ChatInputHandle = (text: string, attachments: Attachment[]) => void
|
||||
|
||||
interface SlashCommand {
|
||||
cmd: string
|
||||
desc: string
|
||||
action: 'new' | 'clear'
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string, attachments: Attachment[]) => void
|
||||
onNewChat: () => void
|
||||
disabled: boolean
|
||||
onStop: () => void
|
||||
onClearContext: () => void
|
||||
isStreaming: boolean
|
||||
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 [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
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 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 trimmed = text.trim()
|
||||
if (!trimmed && attachments.length === 0) return
|
||||
if (disabled) return
|
||||
|
||||
if (isStreaming) return
|
||||
onSend(trimmed, attachments)
|
||||
setText('')
|
||||
setAttachments([])
|
||||
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = '42px'
|
||||
}
|
||||
}, [text, attachments, disabled, onSend])
|
||||
setSlashOpen(false)
|
||||
resetHeight()
|
||||
}, [text, attachments, isStreaming, onSend])
|
||||
|
||||
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()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setText(e.target.value)
|
||||
const v = e.target.value
|
||||
setText(v)
|
||||
const el = e.target
|
||||
el.style.height = '42px'
|
||||
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 files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const uploadFiles = async (files: File[]) => {
|
||||
if (!files.length) return
|
||||
setUploading(true)
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
for (const file of files) {
|
||||
const result = await apiClient.uploadFile(file, sessionId)
|
||||
if (result.status === 'success') {
|
||||
setAttachments((prev) => [
|
||||
@@ -59,7 +131,7 @@ const ChatInput: React.FC<ChatInputProps> = ({ onSend, onNewChat, disabled, sess
|
||||
{
|
||||
file_path: result.file_path,
|
||||
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,
|
||||
},
|
||||
])
|
||||
@@ -69,17 +141,87 @@ const ChatInput: React.FC<ChatInputProps> = ({ onSend, onNewChat, disabled, sess
|
||||
console.error('Upload failed:', err)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const removeAttachment = (index: number) => {
|
||||
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 (
|
||||
<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="max-w-3xl mx-auto">
|
||||
<div className="flex-shrink-0 border-t border-default bg-surface px-4 py-3">
|
||||
<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 */}
|
||||
{attachments.length > 0 && (
|
||||
<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">
|
||||
{att.file_type === 'image' && att.preview_url ? (
|
||||
<div className="relative">
|
||||
<img src={apiClient.getFileUrl(att.preview_url)} alt={att.file_name}
|
||||
className="w-16 h-16 rounded-lg object-cover border border-slate-200 dark:border-white/10" />
|
||||
<button onClick={() => removeAttachment(i)}
|
||||
className="absolute -top-1 -right-1 w-[18px] h-[18px] rounded-full bg-red-500 text-white flex items-center justify-center cursor-pointer">
|
||||
<i className="fas fa-times text-[8px]" />
|
||||
<img
|
||||
src={apiClient.getFileUrl(att.preview_url)}
|
||||
alt={att.file_name}
|
||||
className="w-16 h-16 rounded-lg object-cover border border-default"
|
||||
/>
|
||||
<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>
|
||||
</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">
|
||||
<i className="fas fa-file text-[10px]" />
|
||||
<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">
|
||||
<FileIcon size={12} />
|
||||
<span className="truncate">{att.file_name}</span>
|
||||
<button onClick={() => removeAttachment(i)}
|
||||
className="absolute -top-1 -right-1 w-[18px] h-[18px] rounded-full bg-red-500 text-white flex items-center justify-center cursor-pointer">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -109,26 +258,32 @@ const ChatInput: React.FC<ChatInputProps> = ({ onSend, onNewChat, disabled, sess
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center flex-shrink-0">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex items-center flex-shrink-0 gap-0.5 pb-0.5">
|
||||
<button
|
||||
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"
|
||||
title="New Chat"
|
||||
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={t('session_new')}
|
||||
>
|
||||
<i className="fas fa-plus text-base" />
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
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"
|
||||
title="Attach file"
|
||||
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={t('chat_attach')}
|
||||
>
|
||||
<i className="fas fa-paperclip text-base" />
|
||||
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Paperclip size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<input 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" />
|
||||
<input
|
||||
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
|
||||
ref={textareaRef}
|
||||
@@ -136,23 +291,36 @@ const ChatInput: React.FC<ChatInputProps> = ({ onSend, onNewChat, disabled, sess
|
||||
value={text}
|
||||
onChange={handleTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={() => (composingRef.current = true)}
|
||||
onCompositionEnd={() => (composingRef.current = false)}
|
||||
placeholder={t('input_placeholder')}
|
||||
disabled={disabled}
|
||||
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"
|
||||
/>
|
||||
|
||||
{isStreaming ? (
|
||||
<button
|
||||
onClick={onStop}
|
||||
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')}
|
||||
>
|
||||
<Square size={15} className="fill-current" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || (!text.trim() && attachments.length === 0)}
|
||||
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"
|
||||
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')}
|
||||
>
|
||||
<i className="fas fa-paper-plane text-sm" />
|
||||
<Send size={17} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
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 ReactMarkdown from 'react-markdown'
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
||||
import type { ChatMessage, ToolCall } from '../types'
|
||||
import { Copy, Check, RefreshCw, Pencil, Trash2, File as FileIcon, Sprout } from 'lucide-react'
|
||||
import type { ChatMessage } from '../types'
|
||||
import { t } from '../i18n'
|
||||
import apiClient from '../api/client'
|
||||
import Markdown from './Markdown'
|
||||
import MessageSteps, { ThinkingStep } from './MessageSteps'
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: ChatMessage
|
||||
theme: 'light' | 'dark'
|
||||
baseUrl: string
|
||||
onRegenerate?: (id: string) => void
|
||||
onEdit?: (id: string) => void
|
||||
onDelete?: (msg: ChatMessage) => void
|
||||
}
|
||||
|
||||
const ToolStep: React.FC<{ tool: ToolCall; theme: 'light' | 'dark' }> = ({ tool, theme }) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const isRunning = tool.type === 'tool_start' && !tool.status
|
||||
const isSuccess = tool.status === 'success'
|
||||
function fmtTime(ts: number): string {
|
||||
if (!ts) return ''
|
||||
const d = new Date(ts * 1000)
|
||||
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)}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const MessageBubble: React.FC<MessageBubbleProps> = ({ message, theme, baseUrl }) => {
|
||||
const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, onEdit, onDelete }) => {
|
||||
const isUser = message.role === 'user'
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = (text: string, id: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopiedId(id)
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(message.content)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1800)
|
||||
}
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="flex justify-end px-4 sm:px-6 py-3">
|
||||
<div className="max-w-[75%] sm:max-w-[60%]">
|
||||
{/* User attachments */}
|
||||
<div className="group flex flex-col items-end px-4 sm:px-6 py-2">
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-2 justify-end">
|
||||
{message.attachments.map((att, i) => (
|
||||
<div key={i}>
|
||||
{att.file_type === 'image' && att.preview_url ? (
|
||||
<img src={`${baseUrl}${att.preview_url}`} alt={att.file_name}
|
||||
className="max-w-[200px] max-h-[160px] rounded-lg object-cover" />
|
||||
<div className="flex flex-wrap gap-2 mb-1.5 justify-end max-w-[75%]">
|
||||
{message.attachments.map((att, i) =>
|
||||
att.file_type === 'image' && att.preview_url ? (
|
||||
<img
|
||||
key={i}
|
||||
src={apiClient.getFileUrl(att.preview_url)}
|
||||
alt={att.file_name}
|
||||
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">
|
||||
<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 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>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="flex gap-3 px-4 sm:px-6 py-3">
|
||||
<img src="./logo.jpg" alt="CowAgent" className="w-8 h-8 rounded-lg flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-2xl overflow-hidden">
|
||||
{/* Agent tool steps */}
|
||||
{message.toolCalls && message.toolCalls.length > 0 && (
|
||||
<div className="border-b border-slate-200 dark:border-white/8">
|
||||
{message.toolCalls.map((tool, i) => (
|
||||
<ToolStep key={i} tool={tool} theme={theme} />
|
||||
))}
|
||||
<div className="group flex gap-3 px-4 sm:px-6 py-2">
|
||||
<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 max-w-[calc(100%-2.5rem)]">
|
||||
<div className="inline-block w-full rounded-2xl border border-default bg-surface px-4 py-3">
|
||||
{message.kind === 'evolution' && (
|
||||
<div className="inline-flex items-center gap-1 mb-1.5 text-[11px] text-content-tertiary">
|
||||
<Sprout size={11} />
|
||||
{t('msg_self_learned')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Answer content */}
|
||||
<div className="px-4 py-3 msg-content text-sm text-slate-800 dark:text-slate-200">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const codeStr = String(children).replace(/\n$/, '')
|
||||
if (match) {
|
||||
const codeId = `code-${message.id}-${match[1]}-${codeStr.length}`
|
||||
return (
|
||||
<div className="relative group">
|
||||
<button
|
||||
onClick={() => handleCopy(codeStr, codeId)}
|
||||
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"
|
||||
>
|
||||
<i className={`fas ${copiedId === codeId ? 'fa-check' : 'fa-copy'} text-xs`} />
|
||||
</button>
|
||||
<SyntaxHighlighter
|
||||
style={theme === 'dark' ? oneDark : oneLight}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
>
|
||||
{codeStr}
|
||||
</SyntaxHighlighter>
|
||||
{/* Steps area (thinking / tools / intermediate content), web-aligned:
|
||||
muted, separated from the final answer by a dashed divider. */}
|
||||
{(hasSteps || hasLiveReasoning) && (
|
||||
<div className="mb-2.5 pb-2 border-b border-dashed border-default">
|
||||
{hasLiveReasoning && <ThinkingStep content={message.reasoning!} streaming />}
|
||||
{hasSteps && <MessageSteps steps={message.steps!} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <code {...props}>{children}</code>
|
||||
},
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
{message.isStreaming && (
|
||||
<span className="inline-block w-[6px] h-[14px] bg-primary-400 ml-0.5 animate-blink" />
|
||||
)}
|
||||
|
||||
{/* Final answer */}
|
||||
{message.content && <Markdown content={message.content} />}
|
||||
|
||||
{showCursor && (
|
||||
<div className="flex items-center gap-1 py-0.5">
|
||||
<span className="typing-dot" />
|
||||
<span className="typing-dot" />
|
||||
<span className="typing-dot" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.isStreaming && message.content && (
|
||||
<span className="inline-block w-[6px] h-[14px] bg-accent ml-0.5 align-middle animate-blink" />
|
||||
)}
|
||||
|
||||
{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>
|
||||
|
||||
{/* 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">
|
||||
<span className="text-[11px] text-content-tertiary mr-1">{fmtTime(message.timestamp)}</span>
|
||||
<HoverAction onClick={copy} title={t('msg_copy')}>
|
||||
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||
</HoverAction>
|
||||
{onRegenerate && (
|
||||
<HoverAction onClick={() => onRegenerate(message.id)} title={t('msg_regenerate')}>
|
||||
<RefreshCw size={13} />
|
||||
</HoverAction>
|
||||
)}
|
||||
</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_tasks: '定时',
|
||||
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还能通过长期记忆不断成长。',
|
||||
example_sys_title: '系统管理',
|
||||
example_sys_text: '帮我查看工作空间里有哪些文件',
|
||||
@@ -85,6 +113,34 @@ const translations: Record<string, Record<string, string>> = {
|
||||
menu_channels: 'Channels',
|
||||
menu_tasks: 'Tasks',
|
||||
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.',
|
||||
example_sys_title: 'System',
|
||||
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 ChatInput from '../components/ChatInput'
|
||||
import { useTheme } from '../hooks/useTheme'
|
||||
import ChatInput, { type ChatInputHandle } from '../components/ChatInput'
|
||||
import { t } from '../i18n'
|
||||
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 {
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
const SUGGESTIONS = ['example_sys', 'example_task', 'example_code'] as const
|
||||
|
||||
const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [sessionId, setSessionId] = useState(() => `session_${Date.now().toString(36)}`)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const { theme } = useTheme()
|
||||
const activeId = useSessionStore((s) => s.activeId)
|
||||
const newSession = useSessionStore((s) => s.newSession)
|
||||
const loadSessions = useSessionStore((s) => s.loadSessions)
|
||||
|
||||
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(() => {
|
||||
apiClient.setBaseUrl(baseUrl)
|
||||
}, [baseUrl])
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
// Load history when switching to a session that hasn't been loaded yet.
|
||||
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(() => {
|
||||
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])
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (text: string, attachments: Attachment[]) => {
|
||||
const sid = activeId
|
||||
const isFirst = (useChatStore.getState().sessions[sid]?.messages.length ?? 0) === 0
|
||||
titlePendingRef.current = isFirst
|
||||
await send(sid, text, attachments)
|
||||
// After the first message, refresh the list and ask backend to title it.
|
||||
if (isFirst) {
|
||||
try {
|
||||
await apiClient.generateSessionTitle(sid, text)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
loadSessions(1)
|
||||
titlePendingRef.current = false
|
||||
}
|
||||
},
|
||||
[activeId, send, loadSessions]
|
||||
)
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
setMessages([])
|
||||
setSessionId(`session_${Date.now().toString(36)}`)
|
||||
}, [])
|
||||
|
||||
const handleSend = useCallback(async (text: string, attachments: Attachment[]) => {
|
||||
const userMessage: ChatMessage = {
|
||||
id: `user_${Date.now()}`,
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: Date.now() / 1000,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
}
|
||||
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)
|
||||
const id = newSession()
|
||||
ensureSession(id)
|
||||
loadHistory(id, 1)
|
||||
}, [newSession, ensureSession, loadHistory])
|
||||
|
||||
const handleClearContext = useCallback(async () => {
|
||||
try {
|
||||
const response = await apiClient.sendMessage(
|
||||
sessionId, text, true,
|
||||
attachments.length > 0 ? attachments : undefined
|
||||
await apiClient.clearContext(activeId)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [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]
|
||||
)
|
||||
|
||||
if (response.status === 'success' && response.stream) {
|
||||
const eventSource = apiClient.createSSEStream(response.request_id)
|
||||
const handleDelete = useCallback(
|
||||
(msg: ChatMessage) => {
|
||||
if (msg.userSeq != null) deleteMessage(activeId, msg.userSeq, true)
|
||||
},
|
||||
[deleteMessage, activeId]
|
||||
)
|
||||
|
||||
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,
|
||||
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)
|
||||
})
|
||||
}
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantId ? { ...msg, toolCalls: [...(msg.toolCalls || []), toolCall] } : msg
|
||||
},
|
||||
[activeId, loadHistory, loadingMore, isStreaming]
|
||||
)
|
||||
)
|
||||
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()
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantId ? { ...msg, content: `Connection error: ${err}`, isStreaming: false } : msg
|
||||
)
|
||||
)
|
||||
setIsStreaming(false)
|
||||
}
|
||||
}, [sessionId])
|
||||
const isEmpty = messages.length === 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{messages.length === 0 ? (
|
||||
/* Welcome Screen — exact match with Web console */
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto" onScroll={handleScroll}>
|
||||
{loadingMore && (
|
||||
<div className="flex items-center justify-center py-3 text-content-tertiary">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty ? (
|
||||
<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" />
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-slate-100 mb-3">CowAgent</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-center max-w-lg mb-10 leading-relaxed whitespace-pre-line">
|
||||
{t('welcome_subtitle')}
|
||||
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-5 shadow-md" />
|
||||
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
|
||||
<p className="text-content-tertiary text-sm text-center max-w-md mb-8">{t('chat_empty_hint')}</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 w-full max-w-2xl">
|
||||
{SUGGESTIONS.map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
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="font-medium text-sm text-content mb-1">
|
||||
{t(`${key}_title` as Parameters<typeof t>[0])}
|
||||
</div>
|
||||
<p className="text-xs text-content-tertiary leading-relaxed line-clamp-2">
|
||||
{t(`${key}_text` as Parameters<typeof t>[0])}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 w-full max-w-2xl">
|
||||
{/* System card */}
|
||||
<div
|
||||
onClick={() => handleSend(t('example_sys_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-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<i className="fas fa-folder-open text-blue-500 text-xs" />
|
||||
</div>
|
||||
<span className="font-medium text-sm text-slate-700 dark:text-slate-200">{t('example_sys_title')}</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 leading-relaxed">{t('example_sys_text')}</p>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2">
|
||||
<div className="py-3 max-w-3xl mx-auto">
|
||||
{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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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