feat(desktop): align chat UI with web console

This commit is contained in:
zhayujie
2026-06-29 12:17:35 +08:00
parent e536232963
commit 2959cfea32
6 changed files with 74 additions and 27 deletions

View File

@@ -1,5 +1,5 @@
import React, { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } 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 { Plus, Paperclip, Send, Square, X, File as FileIcon, Loader2, Trash2 } from 'lucide-react'
import { t } from '../i18n' import { t } from '../i18n'
import type { Attachment } from '../types' import type { Attachment } from '../types'
import apiClient from '../api/client' import apiClient from '../api/client'
@@ -59,10 +59,31 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
] ]
const filtered = slashCommands.filter((c) => c.cmd.startsWith(text.trim().toLowerCase())) const filtered = slashCommands.filter((c) => c.cmd.startsWith(text.trim().toLowerCase()))
const resetHeight = () => { // Resize the textarea to fit its content (single line = 42px, capped at
if (textareaRef.current) textareaRef.current.style.height = '42px' // 180px). Keep overflow hidden until we hit the cap, so an empty/short input
// never shows a scrollbar (matches the web console behavior).
const autoSize = (el: HTMLTextAreaElement | null) => {
if (!el) return
el.style.height = '42px'
const h = Math.min(el.scrollHeight, 180)
el.style.height = h + 'px'
el.style.overflowY = el.scrollHeight > 180 ? 'auto' : 'hidden'
} }
const resetHeight = () => {
const el = textareaRef.current
if (!el) return
el.style.height = '42px'
el.style.overflowY = 'hidden'
}
// Sync the height once on mount so the very first render matches the 42px
// single-line height instead of the browser's default textarea size.
useEffect(() => {
autoSize(textareaRef.current)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Allow the parent to load a draft (e.g. when editing a past user message). // Allow the parent to load a draft (e.g. when editing a past user message).
useImperativeHandle(ref, () => (draft: string, atts: Attachment[]) => { useImperativeHandle(ref, () => (draft: string, atts: Attachment[]) => {
setText(draft) setText(draft)
@@ -71,8 +92,7 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
const el = textareaRef.current const el = textareaRef.current
if (el) { if (el) {
el.focus() el.focus()
el.style.height = '42px' autoSize(el)
el.style.height = Math.min(el.scrollHeight, 180) + 'px'
} }
}) })
}) })
@@ -148,9 +168,7 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const v = e.target.value const v = e.target.value
setText(v) setText(v)
const el = e.target autoSize(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 // open slash menu when the input starts with "/" and has no space
setSlashOpen(v.startsWith('/') && !v.includes(' ')) setSlashOpen(v.startsWith('/') && !v.includes(' '))
setSlashIndex(0) setSlashIndex(0)
@@ -321,6 +339,13 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
> >
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Paperclip size={18} />} {uploading ? <Loader2 size={18} className="animate-spin" /> : <Paperclip size={18} />}
</button> </button>
<button
onClick={onClearContext}
className="w-9 h-9 flex items-center justify-center rounded-btn text-content-secondary hover:text-danger hover:bg-danger-soft cursor-pointer transition-colors"
title={t('chat_clear_context')}
>
<Trash2 size={18} />
</button>
</div> </div>
<input <input
ref={fileInputRef} ref={fileInputRef}
@@ -342,7 +367,7 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
onCompositionEnd={() => (composingRef.current = false)} onCompositionEnd={() => (composingRef.current = false)}
placeholder={t('input_placeholder')} placeholder={t('input_placeholder')}
rows={1} rows={1}
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" 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 overflow-y-hidden"
/> />
{isStreaming ? ( {isStreaming ? (
@@ -357,7 +382,7 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!canSend} 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" 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:bg-surface-2 disabled:text-content-disabled disabled:cursor-not-allowed cursor-pointer transition-colors"
title={t('chat_send')} title={t('chat_send')}
> >
<Send size={17} /> <Send size={17} />

View File

@@ -68,7 +68,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
)} )}
</div> </div>
)} )}
<div className="max-w-[75%] rounded-2xl rounded-br-md px-4 py-2.5 bg-[var(--user-bubble-bg)] text-content"> <div className="max-w-[75%] rounded-2xl rounded-br-md px-4 py-2.5 bg-accent text-white">
<div className="text-sm whitespace-pre-wrap break-words">{message.content}</div> <div className="text-sm whitespace-pre-wrap break-words">{message.content}</div>
</div> </div>
<div className="flex items-center gap-0.5 mt-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-0.5 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { ChevronRight, Loader2, Check, X, Brain, Wrench } from 'lucide-react' import { ChevronRight, Loader2, Check, X, Brain } from 'lucide-react'
import type { MessageStep } from '../types' import type { MessageStep } from '../types'
import Markdown from './Markdown' import Markdown from './Markdown'
@@ -49,7 +49,6 @@ const ToolStep: React.FC<{ step: MessageStep }> = ({ step }) => {
onClick={() => setExpanded((v) => !v)} onClick={() => setExpanded((v) => !v)}
> >
<span className="flex-shrink-0">{icon}</span> <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> <span className={`font-medium ${isError ? 'text-danger' : ''}`}>{step.name}</span>
{step.execution_time !== undefined && ( {step.execution_time !== undefined && (
<span className="opacity-60">{step.execution_time}s</span> <span className="opacity-60">{step.execution_time}s</span>

View File

@@ -122,11 +122,17 @@ const translations: Record<string, Record<string, string>> = {
chat_empty_hint: '发送一条消息开始对话', chat_empty_hint: '发送一条消息开始对话',
welcome_subtitle: '我可以帮你解答问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。', welcome_subtitle: '我可以帮你解答问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
example_sys_title: '系统管理', example_sys_title: '系统管理',
example_sys_text: '帮我查看工作空间里有哪些文件', example_sys_text: '查看工作空间里有哪些文件',
example_task_title: '技能系统', example_task_title: '定时任务',
example_task_text: '查看所有支持的工具和技能', example_task_text: '1分钟后提醒我检查服务器',
example_code_title: '编程助手', example_code_title: '编程助手',
example_code_text: '帮我编写一个Python爬虫脚本', example_code_text: '搜索AI资讯并生成可视化网页报告',
example_knowledge_title: '知识库',
example_knowledge_text: '查看知识库当前文档情况',
example_skill_title: '技能系统',
example_skill_text: '查看所有支持的工具和技能',
example_web_title: '指令中心',
example_web_text: '查看全部命令',
input_placeholder: '输入消息...', input_placeholder: '输入消息...',
config_title: '配置管理', config_title: '配置管理',
config_desc: '管理模型和 Agent 配置', config_desc: '管理模型和 Agent 配置',
@@ -473,10 +479,16 @@ const translations: Record<string, Record<string, string>> = {
welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills,\nand keep growing through long-term memory.', welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills,\nand keep growing through long-term memory.',
example_sys_title: 'System', example_sys_title: 'System',
example_sys_text: 'Show me the files in the workspace', example_sys_text: 'Show me the files in the workspace',
example_task_title: 'Skills', example_task_title: 'Scheduled Task',
example_task_text: 'Show current tools and skills', example_task_text: 'Remind me to check the server in 1 minute',
example_code_title: 'Coding', example_code_title: 'Coding',
example_code_text: 'Write a Python web scraper script', example_code_text: 'Search AI news and build a visual web report',
example_knowledge_title: 'Knowledge Base',
example_knowledge_text: 'Show the current documents in the knowledge base',
example_skill_title: 'Skills',
example_skill_text: 'Show all supported tools and skills',
example_web_title: 'Commands',
example_web_text: 'Show all commands',
input_placeholder: 'Type a message...', input_placeholder: 'Type a message...',
config_title: 'Configuration', config_title: 'Configuration',
config_desc: 'Manage model and agent settings', config_desc: 'Manage model and agent settings',

View File

@@ -49,7 +49,6 @@
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12); --shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12);
/* Chat-specific tokens (AI-Native UI) */ /* Chat-specific tokens (AI-Native UI) */
--user-bubble-bg: var(--accent-soft);
--ai-bubble-bg: transparent; --ai-bubble-bg: transparent;
--message-gap: 16px; --message-gap: 16px;
@@ -80,8 +79,6 @@
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.3); --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5); --shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
--user-bubble-bg: rgba(74, 190, 110, 0.16);
color-scheme: dark; color-scheme: dark;
} }

View File

@@ -12,7 +12,17 @@ interface ChatPageProps {
baseUrl: string baseUrl: string
} }
const SUGGESTIONS = ['example_sys', 'example_task', 'example_code'] as const // Welcome-screen suggestion cards (aligned with the web console: 6 cards).
// `send` overrides the text dropped into the input (e.g. show "查看全部命令"
// but fill "/help"); otherwise the card's *_text is used.
const SUGGESTIONS: { key: string; send?: string }[] = [
{ key: 'example_sys' },
{ key: 'example_task' },
{ key: 'example_code' },
{ key: 'example_knowledge' },
{ key: 'example_skill' },
{ key: 'example_web', send: '/help' },
]
const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => { const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
const activeId = useSessionStore((s) => s.activeId) const activeId = useSessionStore((s) => s.activeId)
@@ -210,11 +220,15 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1> <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> <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"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full max-w-2xl">
{SUGGESTIONS.map((key) => ( {SUGGESTIONS.map(({ key, send }) => (
<button <button
key={key} key={key}
onClick={() => handleSend(t(`${key}_text` as Parameters<typeof t>[0]), [])} onClick={() => {
// Fill the input (don't auto-send) so the user can tweak it first.
const draft = send ?? t(`${key}_text` as Parameters<typeof t>[0])
inputResetRef.current?.(draft, [])
}}
className="text-left bg-surface border border-default rounded-xl p-3.5 cursor-pointer hover:border-accent hover:shadow-sm transition-all" 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"> <div className="font-medium text-sm text-content mb-1">