mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
- 抽离初始化代理连接逻辑到单独的方法中 - 优化面板恢复时的代理连接流程 - 移除 EmptyState 组件中的信息横幅 - 在 App 组件中添加可关闭的信息横幅 - 调整输入表单样式,移除冗余样式
629 lines
22 KiB
TypeScript
629 lines
22 KiB
TypeScript
/**
|
||
* @license
|
||
* Copyright 2025 Qwen Team
|
||
* SPDX-License-Identifier: Apache-2.0
|
||
*/
|
||
|
||
import React, { useState, useEffect, useRef } from 'react';
|
||
import { useVSCode } from './hooks/useVSCode.js';
|
||
import type { Conversation } from '../storage/ConversationStore.js';
|
||
import {
|
||
PermissionRequest,
|
||
type PermissionOption,
|
||
type ToolCall as PermissionToolCall,
|
||
} from './components/PermissionRequest.js';
|
||
import { ToolCall, type ToolCallData } from './components/ToolCall.js';
|
||
import { EmptyState } from './components/EmptyState.js';
|
||
|
||
interface ToolCallUpdate {
|
||
type: 'tool_call' | 'tool_call_update';
|
||
toolCallId: string;
|
||
kind?: string;
|
||
title?: string;
|
||
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||
rawInput?: unknown;
|
||
content?: Array<{
|
||
type: 'content' | 'diff';
|
||
content?: {
|
||
type: string;
|
||
text?: string;
|
||
[key: string]: unknown;
|
||
};
|
||
path?: string;
|
||
oldText?: string | null;
|
||
newText?: string;
|
||
[key: string]: unknown;
|
||
}>;
|
||
locations?: Array<{
|
||
path: string;
|
||
line?: number | null;
|
||
}>;
|
||
}
|
||
|
||
interface TextMessage {
|
||
role: 'user' | 'assistant' | 'thinking';
|
||
content: string;
|
||
timestamp: number;
|
||
}
|
||
|
||
export const App: React.FC = () => {
|
||
const vscode = useVSCode();
|
||
const [messages, setMessages] = useState<TextMessage[]>([]);
|
||
const [inputText, setInputText] = useState('');
|
||
const [isStreaming, setIsStreaming] = useState(false);
|
||
const [currentStreamContent, setCurrentStreamContent] = useState('');
|
||
const [qwenSessions, setQwenSessions] = useState<
|
||
Array<Record<string, unknown>>
|
||
>([]);
|
||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||
const [showSessionSelector, setShowSessionSelector] = useState(false);
|
||
const [permissionRequest, setPermissionRequest] = useState<{
|
||
options: PermissionOption[];
|
||
toolCall: PermissionToolCall;
|
||
} | null>(null);
|
||
const [toolCalls, setToolCalls] = useState<Map<string, ToolCallData>>(
|
||
new Map(),
|
||
);
|
||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||
const inputFieldRef = useRef<HTMLDivElement>(null);
|
||
const [showBanner, setShowBanner] = useState(true);
|
||
|
||
const handlePermissionRequest = React.useCallback(
|
||
(request: {
|
||
options: PermissionOption[];
|
||
toolCall: PermissionToolCall;
|
||
}) => {
|
||
console.log('[WebView] Permission request received:', request);
|
||
setPermissionRequest(request);
|
||
},
|
||
[],
|
||
);
|
||
|
||
const handlePermissionResponse = React.useCallback(
|
||
(optionId: string) => {
|
||
console.log('[WebView] Sending permission response:', optionId);
|
||
vscode.postMessage({
|
||
type: 'permissionResponse',
|
||
data: { optionId },
|
||
});
|
||
setPermissionRequest(null);
|
||
},
|
||
[vscode],
|
||
);
|
||
|
||
const handleToolCallUpdate = React.useCallback((update: ToolCallUpdate) => {
|
||
setToolCalls((prev) => {
|
||
const newMap = new Map(prev);
|
||
const existing = newMap.get(update.toolCallId);
|
||
|
||
if (update.type === 'tool_call') {
|
||
// New tool call - cast content to proper type
|
||
const content = update.content?.map((item) => ({
|
||
type: item.type as 'content' | 'diff',
|
||
content: item.content,
|
||
path: item.path,
|
||
oldText: item.oldText,
|
||
newText: item.newText,
|
||
}));
|
||
|
||
newMap.set(update.toolCallId, {
|
||
toolCallId: update.toolCallId,
|
||
kind: update.kind || 'other',
|
||
title: update.title || 'Tool Call',
|
||
status: update.status || 'pending',
|
||
rawInput: update.rawInput as string | object | undefined,
|
||
content,
|
||
locations: update.locations,
|
||
});
|
||
} else if (update.type === 'tool_call_update' && existing) {
|
||
// Update existing tool call
|
||
const updatedContent = update.content
|
||
? update.content.map((item) => ({
|
||
type: item.type as 'content' | 'diff',
|
||
content: item.content,
|
||
path: item.path,
|
||
oldText: item.oldText,
|
||
newText: item.newText,
|
||
}))
|
||
: undefined;
|
||
|
||
newMap.set(update.toolCallId, {
|
||
...existing,
|
||
...(update.kind && { kind: update.kind }),
|
||
...(update.title && { title: update.title }),
|
||
...(update.status && { status: update.status }),
|
||
...(updatedContent && { content: updatedContent }),
|
||
...(update.locations && { locations: update.locations }),
|
||
});
|
||
}
|
||
|
||
return newMap;
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
// Listen for messages from extension
|
||
const handleMessage = (event: MessageEvent) => {
|
||
const message = event.data;
|
||
|
||
switch (message.type) {
|
||
case 'conversationLoaded': {
|
||
const conversation = message.data as Conversation;
|
||
setMessages(conversation.messages);
|
||
break;
|
||
}
|
||
|
||
case 'message': {
|
||
const newMessage = message.data as TextMessage;
|
||
setMessages((prev) => [...prev, newMessage]);
|
||
break;
|
||
}
|
||
|
||
case 'streamStart':
|
||
setIsStreaming(true);
|
||
setCurrentStreamContent('');
|
||
break;
|
||
|
||
case 'streamChunk': {
|
||
const chunkData = message.data;
|
||
if (chunkData.role === 'thinking') {
|
||
// Handle thinking chunks separately if needed
|
||
setCurrentStreamContent((prev) => prev + chunkData.chunk);
|
||
} else {
|
||
setCurrentStreamContent((prev) => prev + chunkData.chunk);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'streamEnd':
|
||
// Finalize the streamed message
|
||
if (currentStreamContent) {
|
||
const assistantMessage: TextMessage = {
|
||
role: 'assistant',
|
||
content: currentStreamContent,
|
||
timestamp: Date.now(),
|
||
};
|
||
setMessages((prev) => [...prev, assistantMessage]);
|
||
}
|
||
setIsStreaming(false);
|
||
setCurrentStreamContent('');
|
||
break;
|
||
|
||
case 'error':
|
||
console.error('Error from extension:', message.data.message);
|
||
setIsStreaming(false);
|
||
break;
|
||
|
||
case 'permissionRequest':
|
||
// Show permission dialog
|
||
handlePermissionRequest(message.data);
|
||
break;
|
||
|
||
case 'toolCall':
|
||
case 'toolCallUpdate':
|
||
// Handle tool call updates
|
||
handleToolCallUpdate(message.data);
|
||
break;
|
||
|
||
case 'qwenSessionList': {
|
||
const sessions = message.data.sessions || [];
|
||
setQwenSessions(sessions);
|
||
// If no current session is selected and there are sessions, select the first one
|
||
if (!currentSessionId && sessions.length > 0) {
|
||
const firstSessionId =
|
||
(sessions[0].id as string) || (sessions[0].sessionId as string);
|
||
if (firstSessionId) {
|
||
setCurrentSessionId(firstSessionId);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'qwenSessionSwitched':
|
||
setShowSessionSelector(false);
|
||
// Update current session ID
|
||
if (message.data.sessionId) {
|
||
setCurrentSessionId(message.data.sessionId as string);
|
||
}
|
||
// Load messages from the session
|
||
if (message.data.messages) {
|
||
setMessages(message.data.messages);
|
||
} else {
|
||
setMessages([]);
|
||
}
|
||
setCurrentStreamContent('');
|
||
setToolCalls(new Map());
|
||
break;
|
||
|
||
case 'conversationCleared':
|
||
setMessages([]);
|
||
setCurrentStreamContent('');
|
||
setToolCalls(new Map());
|
||
break;
|
||
|
||
default:
|
||
break;
|
||
}
|
||
};
|
||
|
||
window.addEventListener('message', handleMessage);
|
||
return () => window.removeEventListener('message', handleMessage);
|
||
}, [
|
||
currentStreamContent,
|
||
currentSessionId,
|
||
handlePermissionRequest,
|
||
handleToolCallUpdate,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
// Auto-scroll to bottom when messages change
|
||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||
}, [messages, currentStreamContent]);
|
||
|
||
// Load sessions on component mount
|
||
useEffect(() => {
|
||
vscode.postMessage({ type: 'getQwenSessions', data: {} });
|
||
}, [vscode]);
|
||
|
||
const handleSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
|
||
if (!inputText.trim() || isStreaming) {
|
||
console.log('Submit blocked:', { inputText, isStreaming });
|
||
return;
|
||
}
|
||
|
||
console.log('Sending message:', inputText);
|
||
vscode.postMessage({
|
||
type: 'sendMessage',
|
||
data: { text: inputText },
|
||
});
|
||
|
||
// Clear input field
|
||
setInputText('');
|
||
if (inputFieldRef.current) {
|
||
inputFieldRef.current.textContent = '';
|
||
}
|
||
};
|
||
|
||
const handleLoadQwenSessions = () => {
|
||
vscode.postMessage({ type: 'getQwenSessions', data: {} });
|
||
setShowSessionSelector(true);
|
||
};
|
||
|
||
const handleNewQwenSession = () => {
|
||
vscode.postMessage({ type: 'newQwenSession', data: {} });
|
||
setShowSessionSelector(false);
|
||
setCurrentSessionId(null);
|
||
// Clear messages in UI
|
||
setMessages([]);
|
||
setCurrentStreamContent('');
|
||
};
|
||
|
||
const handleSwitchSession = (sessionId: string) => {
|
||
if (sessionId === currentSessionId) {
|
||
return;
|
||
}
|
||
|
||
vscode.postMessage({
|
||
type: 'switchQwenSession',
|
||
data: { sessionId },
|
||
});
|
||
setCurrentSessionId(sessionId);
|
||
setShowSessionSelector(false);
|
||
};
|
||
|
||
// Check if there are any messages or active content
|
||
const hasContent =
|
||
messages.length > 0 ||
|
||
isStreaming ||
|
||
toolCalls.size > 0 ||
|
||
permissionRequest !== null;
|
||
|
||
return (
|
||
<div className="chat-container">
|
||
{showSessionSelector && (
|
||
<div className="session-selector-overlay">
|
||
<div className="session-selector">
|
||
<div className="session-selector-header">
|
||
<h3>Past Conversations</h3>
|
||
<button onClick={() => setShowSessionSelector(false)}>✕</button>
|
||
</div>
|
||
<div className="session-selector-actions">
|
||
<button
|
||
className="new-session-button"
|
||
onClick={handleNewQwenSession}
|
||
>
|
||
➕ New Session
|
||
</button>
|
||
</div>
|
||
<div className="session-list">
|
||
{qwenSessions.length === 0 ? (
|
||
<p className="no-sessions">No sessions available</p>
|
||
) : (
|
||
qwenSessions.map((session) => {
|
||
const sessionId =
|
||
(session.id as string) ||
|
||
(session.sessionId as string) ||
|
||
'';
|
||
const title =
|
||
(session.title as string) ||
|
||
(session.name as string) ||
|
||
'Untitled Session';
|
||
const lastUpdated =
|
||
(session.lastUpdated as string) ||
|
||
(session.startTime as string) ||
|
||
'';
|
||
const messageCount = (session.messageCount as number) || 0;
|
||
|
||
return (
|
||
<div
|
||
key={sessionId}
|
||
className="session-item"
|
||
onClick={() => handleSwitchSession(sessionId)}
|
||
>
|
||
<div className="session-title">{title}</div>
|
||
<div className="session-meta">
|
||
<span className="session-time">
|
||
{new Date(lastUpdated).toLocaleString()}
|
||
</span>
|
||
<span className="session-count">
|
||
{messageCount} messages
|
||
</span>
|
||
</div>
|
||
<div className="session-id">
|
||
{sessionId.substring(0, 8)}...
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="chat-header">
|
||
<button
|
||
className="header-conversations-button"
|
||
onClick={handleLoadQwenSessions}
|
||
title="Past conversations"
|
||
>
|
||
<span className="button-content">
|
||
<span className="button-text">Past Conversations</span>
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
viewBox="0 0 20 20"
|
||
fill="currentColor"
|
||
aria-hidden="true"
|
||
className="dropdown-icon"
|
||
>
|
||
<path
|
||
fillRule="evenodd"
|
||
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
|
||
clipRule="evenodd"
|
||
></path>
|
||
</svg>
|
||
</span>
|
||
</button>
|
||
<div className="header-spacer"></div>
|
||
<button
|
||
className="new-session-header-button"
|
||
onClick={handleNewQwenSession}
|
||
title="New Session"
|
||
>
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
viewBox="0 0 20 20"
|
||
fill="currentColor"
|
||
aria-hidden="true"
|
||
data-slot="icon"
|
||
className="icon-svg"
|
||
>
|
||
<path d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="messages-container">
|
||
{!hasContent ? (
|
||
<EmptyState />
|
||
) : (
|
||
<>
|
||
{messages.map((msg, index) => (
|
||
<div key={index} className={`message ${msg.role}`}>
|
||
<div className="message-content">{msg.content}</div>
|
||
<div className="message-timestamp">
|
||
{new Date(msg.timestamp).toLocaleTimeString()}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{/* Tool Calls */}
|
||
{Array.from(toolCalls.values()).map((toolCall) => (
|
||
<ToolCall key={toolCall.toolCallId} toolCall={toolCall} />
|
||
))}
|
||
|
||
{/* Permission Request */}
|
||
{permissionRequest && (
|
||
<PermissionRequest
|
||
options={permissionRequest.options}
|
||
toolCall={permissionRequest.toolCall}
|
||
onResponse={handlePermissionResponse}
|
||
/>
|
||
)}
|
||
|
||
{isStreaming && currentStreamContent && (
|
||
<div className="message assistant streaming">
|
||
<div className="message-content">{currentStreamContent}</div>
|
||
<div className="streaming-indicator">●</div>
|
||
</div>
|
||
)}
|
||
|
||
<div ref={messagesEndRef} />
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Info Banner */}
|
||
{showBanner && (
|
||
<div className="info-banner">
|
||
<div className="banner-content">
|
||
<svg
|
||
className="banner-icon"
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 20 20"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<path d="M5.14648 7.14648C5.34175 6.95122 5.65825 6.95122 5.85352 7.14648L8.35352 9.64648C8.44728 9.74025 8.5 9.86739 8.5 10C8.5 10.0994 8.47037 10.1958 8.41602 10.2773L8.35352 10.3535L5.85352 12.8535C5.65825 13.0488 5.34175 13.0488 5.14648 12.8535C4.95122 12.6583 4.95122 12.3417 5.14648 12.1465L7.29297 10L5.14648 7.85352C4.95122 7.65825 4.95122 7.34175 5.14648 7.14648Z"></path>
|
||
<path d="M14.5 12C14.7761 12 15 12.2239 15 12.5C15 12.7761 14.7761 13 14.5 13H9.5C9.22386 13 9 12.7761 9 12.5C9 12.2239 9.22386 12 9.5 12H14.5Z"></path>
|
||
<path
|
||
fillRule="evenodd"
|
||
clipRule="evenodd"
|
||
d="M16.5 4C17.3284 4 18 4.67157 18 5.5V14.5C18 15.3284 17.3284 16 16.5 16H3.5C2.67157 16 2 15.3284 2 14.5V5.5C2 4.67157 2.67157 4 3.5 4H16.5ZM3.5 5C3.22386 5 3 5.22386 3 5.5V14.5C3 14.7761 3.22386 15 3.5 15H16.5C16.7761 15 17 14.7761 17 14.5V5.5C17 5.22386 16.7761 5 16.5 5H3.5Z"
|
||
></path>
|
||
</svg>
|
||
<label>
|
||
Prefer the Terminal experience?{' '}
|
||
<a href="#" className="banner-link">
|
||
Switch back in Settings.
|
||
</a>
|
||
</label>
|
||
</div>
|
||
<button
|
||
className="banner-close"
|
||
aria-label="Close banner"
|
||
onClick={() => setShowBanner(false)}
|
||
>
|
||
<svg
|
||
width="10"
|
||
height="10"
|
||
viewBox="0 0 14 14"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<path
|
||
d="M1 1L13 13M1 13L13 1"
|
||
stroke="currentColor"
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="input-form-container">
|
||
<div className="input-form-wrapper">
|
||
<form className="input-form" onSubmit={handleSubmit}>
|
||
<div className="input-banner"></div>
|
||
<div className="input-wrapper">
|
||
<div
|
||
ref={inputFieldRef}
|
||
contentEditable="plaintext-only"
|
||
className="input-field-editable"
|
||
role="textbox"
|
||
aria-label="Message input"
|
||
aria-multiline="true"
|
||
data-placeholder="Ask qwen to edit…"
|
||
onInput={(e) => {
|
||
const target = e.target as HTMLDivElement;
|
||
setInputText(target.textContent || '');
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSubmit(e);
|
||
}
|
||
}}
|
||
suppressContentEditableWarning
|
||
/>
|
||
</div>
|
||
<div className="input-actions">
|
||
<button
|
||
type="button"
|
||
className="action-button edit-mode-button"
|
||
title="Qwen will ask before each edit. Click to switch modes."
|
||
>
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
viewBox="0 0 16 16"
|
||
fill="currentColor"
|
||
aria-hidden="true"
|
||
>
|
||
<path
|
||
fillRule="evenodd"
|
||
d="M11.013 2.513a1.75 1.75 0 0 1 2.475 2.474L6.226 12.25a2.751 2.751 0 0 1-.892.596l-2.047.848a.75.75 0 0 1-.98-.98l.848-2.047a2.75 2.75 0 0 1 .596-.892l7.262-7.261Z"
|
||
clipRule="evenodd"
|
||
></path>
|
||
</svg>
|
||
<span>Ask before edits</span>
|
||
</button>
|
||
<div className="action-divider"></div>
|
||
<button
|
||
type="button"
|
||
className="action-icon-button thinking-button"
|
||
title="Thinking off"
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 16 16"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<path
|
||
d="M8.00293 1.11523L8.35059 1.12402H8.35352C11.9915 1.30834 14.8848 4.31624 14.8848 8C14.8848 11.8025 11.8025 14.8848 8 14.8848C4.19752 14.8848 1.11523 11.8025 1.11523 8C1.11523 7.67691 1.37711 7.41504 1.7002 7.41504C2.02319 7.41514 2.28516 7.67698 2.28516 8C2.28516 11.1563 4.84369 13.7148 8 13.7148C11.1563 13.7148 13.7148 11.1563 13.7148 8C13.7148 4.94263 11.3141 2.4464 8.29492 2.29297V2.29199L7.99609 2.28516H7.9873V2.28418L7.89648 2.27539L7.88281 2.27441V2.27344C7.61596 2.21897 7.41513 1.98293 7.41504 1.7002C7.41504 1.37711 7.67691 1.11523 8 1.11523H8.00293ZM8 3.81543C8.32309 3.81543 8.58496 4.0773 8.58496 4.40039V7.6377L10.9619 8.82715C11.2505 8.97169 11.3678 9.32256 11.2236 9.61133C11.0972 9.86425 10.8117 9.98544 10.5488 9.91504L10.5352 9.91211V9.91016L10.4502 9.87891L10.4385 9.87402V9.87305L7.73828 8.52344C7.54007 8.42433 7.41504 8.22155 7.41504 8V4.40039C7.41504 4.0773 7.67691 3.81543 8 3.81543ZM2.44336 5.12695C2.77573 5.19517 3.02597 5.48929 3.02637 5.8418C3.02637 6.19456 2.7761 6.49022 2.44336 6.55859L2.2959 6.57324C1.89241 6.57324 1.56543 6.24529 1.56543 5.8418C1.56588 5.43853 1.89284 5.1123 2.2959 5.1123L2.44336 5.12695ZM3.46094 2.72949C3.86418 2.72984 4.19017 3.05712 4.19043 3.45996V3.46094C4.19009 3.86393 3.86392 4.19008 3.46094 4.19043H3.45996C3.05712 4.19017 2.72983 3.86419 2.72949 3.46094V3.45996C2.72976 3.05686 3.05686 2.72976 3.45996 2.72949H3.46094ZM5.98926 1.58008C6.32235 1.64818 6.57324 1.94276 6.57324 2.2959L6.55859 2.44336C6.49022 2.7761 6.19456 3.02637 5.8418 3.02637C5.43884 3.02591 5.11251 2.69895 5.1123 2.2959L5.12695 2.14844C5.19504 1.81591 5.48906 1.56583 5.8418 1.56543L5.98926 1.58008Z"
|
||
strokeWidth="0.27"
|
||
style={{
|
||
stroke: 'var(--app-secondary-foreground)',
|
||
fill: 'var(--app-secondary-foreground)',
|
||
}}
|
||
></path>
|
||
</svg>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="action-icon-button command-button"
|
||
title="Show command menu (/)"
|
||
>
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
viewBox="0 0 20 20"
|
||
fill="currentColor"
|
||
aria-hidden="true"
|
||
>
|
||
<path
|
||
fillRule="evenodd"
|
||
d="M12.528 3.047a.75.75 0 0 1 .449.961L8.433 16.504a.75.75 0 1 1-1.41-.512l4.544-12.496a.75.75 0 0 1 .961-.449Z"
|
||
clipRule="evenodd"
|
||
></path>
|
||
</svg>
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="send-button-icon"
|
||
disabled={isStreaming || !inputText.trim()}
|
||
>
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
viewBox="0 0 20 20"
|
||
fill="currentColor"
|
||
aria-hidden="true"
|
||
>
|
||
<path
|
||
fillRule="evenodd"
|
||
d="M10 17a.75.75 0 0 1-.75-.75V5.612L5.29 9.77a.75.75 0 0 1-1.08-1.04l5.25-5.5a.75.75 0 0 1 1.08 0l5.25 5.5a.75.75 0 1 1-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0 1 10 17Z"
|
||
clipRule="evenodd"
|
||
></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|