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:
Claude AI
2026-03-25 00:43:36 +00:00
parent 950a930465
commit 233b5e2cc3
2 changed files with 113 additions and 48 deletions

View File

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

View File

@@ -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="发送消息"
>