feat(desktop): chat core with streaming, sessions, tool steps and markdown-it rendering

This commit is contained in:
zhayujie
2026-06-20 00:40:11 +08:00
parent 3baa3252bc
commit e9d9b566a4
11 changed files with 1490 additions and 1797 deletions

1596
desktop/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"
]
}
]
},

View File

@@ -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

View 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

View File

@@ -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>
)

View 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

View File

@@ -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',

View File

@@ -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>
)
}

View 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 })
},
}
})

View 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()
},
}))

View 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 }),
}))