mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
feat(desktop): align chat UI with web console
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
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 type { Attachment } from '../types'
|
||||
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 resetHeight = () => {
|
||||
if (textareaRef.current) textareaRef.current.style.height = '42px'
|
||||
// Resize the textarea to fit its content (single line = 42px, capped at
|
||||
// 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).
|
||||
useImperativeHandle(ref, () => (draft: string, atts: Attachment[]) => {
|
||||
setText(draft)
|
||||
@@ -71,8 +92,7 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
||||
const el = textareaRef.current
|
||||
if (el) {
|
||||
el.focus()
|
||||
el.style.height = '42px'
|
||||
el.style.height = Math.min(el.scrollHeight, 180) + 'px'
|
||||
autoSize(el)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -148,9 +168,7 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const v = e.target.value
|
||||
setText(v)
|
||||
const el = e.target
|
||||
el.style.height = '42px'
|
||||
el.style.height = Math.min(el.scrollHeight, 180) + 'px'
|
||||
autoSize(e.target)
|
||||
// open slash menu when the input starts with "/" and has no space
|
||||
setSlashOpen(v.startsWith('/') && !v.includes(' '))
|
||||
setSlashIndex(0)
|
||||
@@ -321,6 +339,13 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
||||
>
|
||||
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Paperclip size={18} />}
|
||||
</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>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -342,7 +367,7 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
||||
onCompositionEnd={() => (composingRef.current = false)}
|
||||
placeholder={t('input_placeholder')}
|
||||
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 ? (
|
||||
@@ -357,7 +382,7 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSend}
|
||||
className="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"
|
||||
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')}
|
||||
>
|
||||
<Send size={17} />
|
||||
|
||||
@@ -68,7 +68,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
|
||||
)}
|
||||
</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>
|
||||
<div className="flex items-center gap-0.5 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 Markdown from './Markdown'
|
||||
|
||||
@@ -49,7 +49,6 @@ const ToolStep: React.FC<{ step: MessageStep }> = ({ step }) => {
|
||||
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>
|
||||
|
||||
@@ -122,11 +122,17 @@ const translations: Record<string, Record<string, string>> = {
|
||||
chat_empty_hint: '发送一条消息开始对话',
|
||||
welcome_subtitle: '我可以帮你解答问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
||||
example_sys_title: '系统管理',
|
||||
example_sys_text: '帮我查看工作空间里有哪些文件',
|
||||
example_task_title: '技能系统',
|
||||
example_task_text: '查看所有支持的工具和技能',
|
||||
example_sys_text: '查看工作空间里有哪些文件',
|
||||
example_task_title: '定时任务',
|
||||
example_task_text: '1分钟后提醒我检查服务器',
|
||||
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: '输入消息...',
|
||||
config_title: '配置管理',
|
||||
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.',
|
||||
example_sys_title: 'System',
|
||||
example_sys_text: 'Show me the files in the workspace',
|
||||
example_task_title: 'Skills',
|
||||
example_task_text: 'Show current tools and skills',
|
||||
example_task_title: 'Scheduled Task',
|
||||
example_task_text: 'Remind me to check the server in 1 minute',
|
||||
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...',
|
||||
config_title: 'Configuration',
|
||||
config_desc: 'Manage model and agent settings',
|
||||
|
||||
@@ -49,7 +49,6 @@
|
||||
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12);
|
||||
|
||||
/* Chat-specific tokens (AI-Native UI) */
|
||||
--user-bubble-bg: var(--accent-soft);
|
||||
--ai-bubble-bg: transparent;
|
||||
--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-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
|
||||
|
||||
--user-bubble-bg: rgba(74, 190, 110, 0.16);
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,17 @@ interface ChatPageProps {
|
||||
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 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>
|
||||
<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) => (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full max-w-2xl">
|
||||
{SUGGESTIONS.map(({ key, send }) => (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<div className="font-medium text-sm text-content mb-1">
|
||||
|
||||
Reference in New Issue
Block a user