refactor: 使用 Vercel AI SDK useChat 替换 useChatUI
- 添加 MCP Token 自动传递支持 (X-MCP-Tokens, X-MCP-Enabled) - 使用 useChat hook 替换 @json-render/react 的 useChatUI - 添加 ChatMessage 组件用于消息渲染 - 更新 ChatInput 组件支持受控/非受控两种模式 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,21 @@
|
||||
/**
|
||||
* 主页面 - 聊天界面
|
||||
*
|
||||
* 使用官方 useChatUI hook 集成 json-render 生成式 UI
|
||||
* 使用 Vercel AI SDK useChat hook
|
||||
* 支持动态渲染 MCP 工具调用结果
|
||||
* 支持组件交互事件(ActionProvider)
|
||||
* 支持 MCP Token 自动传递
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { useChatUI, Renderer } from '@json-render/react';
|
||||
import { useChat } from 'ai/react';
|
||||
import ChatInput from '@/components/ChatInput';
|
||||
import ChatMessage from '@/components/ChatMessage';
|
||||
import Header from '@/components/Header';
|
||||
import JsonRenderer from '@/components/JsonRenderer';
|
||||
import { ActionProvider } from '@/lib/action-context';
|
||||
import { registry } from '@/lib/registry';
|
||||
import { McpTokenManager } from '@/lib/mcp-token-manager';
|
||||
import type { ComponentSpec } from '@/lib/json-render-catalog';
|
||||
|
||||
// 静态欢迎组件 spec
|
||||
@@ -95,13 +97,43 @@ const WELCOME_SPEC: ComponentSpec = {
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const { messages, isStreaming, send, clear } = useChatUI({
|
||||
const mcpTokenManager = McpTokenManager.getInstance();
|
||||
|
||||
const { messages, input, handleInputChange, handleSubmit, isLoading, stop } = useChat({
|
||||
api: '/api/chat',
|
||||
headers: async () => {
|
||||
const tokens = mcpTokenManager.getAllTokens();
|
||||
const enabledMcpList = mcpTokenManager.getEnabledMcpList();
|
||||
return {
|
||||
'X-MCP-Tokens': JSON.stringify(tokens),
|
||||
'X-MCP-Enabled': JSON.stringify(enabledMcpList),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// 兼容旧代码的 send 函数
|
||||
const send = async (text: string) => {
|
||||
// 设置输入值并提交表单
|
||||
const formEvent = new Event('submit', { bubbles: true, cancelable: true });
|
||||
await handleSubmit(formEvent);
|
||||
};
|
||||
|
||||
const actionContext = useCallback(() => ({
|
||||
sendMessage: send,
|
||||
}), [send]);
|
||||
sendMessage: (text: string) => {
|
||||
// 直接设置 input 值并提交
|
||||
const form = document.querySelector('form') as HTMLFormElement;
|
||||
if (form) {
|
||||
const textarea = form.querySelector('textarea') as HTMLTextAreaElement;
|
||||
if (textarea) {
|
||||
textarea.value = text;
|
||||
const event = new Event('input', { bubbles: true });
|
||||
textarea.dispatchEvent(event);
|
||||
}
|
||||
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
|
||||
form.dispatchEvent(submitEvent);
|
||||
}
|
||||
},
|
||||
}), []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen-mobile min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
@@ -113,37 +145,34 @@ export default function Home() {
|
||||
<ActionProvider actions={actionContext()}>
|
||||
<div className="flex items-start justify-center h-full pt-4 md:pt-8">
|
||||
<div className="w-full max-w-2xl px-2">
|
||||
<JsonRenderer spec={WELCOME_SPEC} onAction={(action, params) => { if (action === "sendMessage") { send(params?.message || ""); } }} />
|
||||
<JsonRenderer spec={WELCOME_SPEC} onAction={(action, params) => {
|
||||
if (action === "sendMessage") {
|
||||
actionContext().sendMessage(params?.message || "");
|
||||
}
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
</ActionProvider>
|
||||
) : (
|
||||
<ActionProvider actions={actionContext()}>
|
||||
<div className="space-y-3 md:space-y-4">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id}>
|
||||
{msg.role === 'user' ? (
|
||||
<div className="flex justify-end">
|
||||
<div className="bg-blue-500 text-white px-4 py-2 rounded-lg">
|
||||
{msg.text}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg">
|
||||
{msg.text && <div className="prose">{msg.text}</div>}
|
||||
{msg.spec && <Renderer spec={msg.spec} registry={registry} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isStreaming && <div className="text-gray-500">思考中...</div>}
|
||||
</div>
|
||||
</ActionProvider>
|
||||
<div className="space-y-4">
|
||||
{messages.map((msg, idx) => (
|
||||
<ChatMessage
|
||||
key={idx}
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
isStreaming={isLoading && idx === messages.length - 1 && msg.role === 'assistant'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="fixed-bottom-input md:relative border-t dark:border-gray-700 p-2 md:p-4 bg-white dark:bg-gray-800">
|
||||
<ChatInput onSend={send} isLoading={isStreaming} onAbort={clear} />
|
||||
</div>
|
||||
<ChatInput
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
onStop={stop}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,33 +1,69 @@
|
||||
/**
|
||||
* 聊天输入组件 - 移动端优化
|
||||
*
|
||||
* 支持两种模式:
|
||||
* 1. 受控模式:传入 value, onChange, onSubmit
|
||||
* 2. 非受控模式:传入 onSend (兼容旧代码)
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import { useState, FormEvent, KeyboardEvent, useRef, useEffect } from 'react';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
isLoading: boolean;
|
||||
onAbort: () => void;
|
||||
// 受控模式 props (useChat)
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onSubmit?: (e: React.FormEvent) => void;
|
||||
isLoading?: boolean;
|
||||
onStop?: () => void;
|
||||
|
||||
// 非受控模式 props (兼容旧代码)
|
||||
onSend?: (message: string) => void;
|
||||
onAbort?: () => void;
|
||||
}
|
||||
|
||||
export default function ChatInput({ onSend, isLoading, onAbort }: ChatInputProps) {
|
||||
const [input, setInput] = useState('');
|
||||
export default function ChatInput({
|
||||
// 受控模式 props
|
||||
value, onChange, onSubmit, isLoading, onStop,
|
||||
// 非受控模式 props
|
||||
onSend, onAbort
|
||||
}: ChatInputProps) {
|
||||
// 检测是否为受控模式
|
||||
const isControlled = value !== undefined && onChange !== undefined;
|
||||
|
||||
const [localInput, setLocalInput] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// 使用受控或非受控的值
|
||||
const inputValue = isControlled ? value : localInput;
|
||||
const loading = isLoading ?? false;
|
||||
const abortHandler = onStop ?? onAbort;
|
||||
|
||||
// 自动调整 textarea 高度
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 150)}px`;
|
||||
}
|
||||
}, [input]);
|
||||
}, [inputValue]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (isControlled) {
|
||||
onChange(e);
|
||||
} else {
|
||||
setLocalInput(e.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (input.trim() && !isLoading) {
|
||||
onSend(input.trim());
|
||||
setInput('');
|
||||
if (onSubmit) {
|
||||
// 受控模式:直接调用 onSubmit
|
||||
onSubmit(e);
|
||||
} else if (onSend && inputValue.trim() && !loading) {
|
||||
// 非受控模式:调用 onSend 并清空输入
|
||||
onSend(inputValue.trim());
|
||||
setLocalInput('');
|
||||
// 重置 textarea 高度
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
@@ -47,28 +83,28 @@ export default function ChatInput({ onSend, isLoading, onAbort }: ChatInputProps
|
||||
<div className="flex-1 relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入消息... (Shift+Enter 换行)"
|
||||
disabled={isLoading}
|
||||
disabled={loading}
|
||||
rows={1}
|
||||
className="w-full resize-none border border-gray-300 dark:border-gray-600 rounded-xl px-4 py-3 pr-12 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white text-[16px] leading-relaxed transition-all"
|
||||
style={{ minHeight: '48px', maxHeight: '150px' }}
|
||||
/>
|
||||
{/* 字符计数 */}
|
||||
{input.length > 0 && (
|
||||
{inputValue.length > 0 && (
|
||||
<span className="absolute bottom-2 right-3 text-xs text-gray-400">
|
||||
{input.length}
|
||||
{inputValue.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 移动端:图标按钮 */}
|
||||
{isLoading ? (
|
||||
{loading ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAbort}
|
||||
onClick={abortHandler}
|
||||
className="flex items-center justify-center w-12 h-12 min-w-[48px] bg-red-500 hover:bg-red-600 text-white rounded-xl transition-all active:scale-95 shadow-sm"
|
||||
aria-label="停止生成"
|
||||
>
|
||||
@@ -79,7 +115,7 @@ export default function ChatInput({ onSend, isLoading, onAbort }: ChatInputProps
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim()}
|
||||
disabled={!inputValue.trim()}
|
||||
className="flex items-center justify-center w-12 h-12 min-w-[48px] bg-blue-500 hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-xl transition-all active:scale-95 shadow-sm disabled:active:scale-100"
|
||||
aria-label="发送消息"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user