mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
feat(webui): publish webui
0.1.0-beta.3 add collapsibaleFileContext compent and opt SearchToolCall
This commit is contained in:
parent
cf32299b5f
commit
76505c635e
6 changed files with 326 additions and 40 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/webui",
|
||||
"version": "0.1.0-beta.2",
|
||||
"version": "0.1.0-beta.3",
|
||||
"description": "Shared UI components for Qwen Code packages",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { FC } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { MessageContent } from './MessageContent.js';
|
||||
|
||||
/**
|
||||
* Parsed segment of user message content
|
||||
*/
|
||||
export interface ContentSegment {
|
||||
type: 'text' | 'file_reference';
|
||||
content: string;
|
||||
/** File path for file_reference type */
|
||||
filePath?: string;
|
||||
/** File name extracted from path */
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pattern markers for file reference content
|
||||
*/
|
||||
const FILE_REFERENCE_START = '--- Content from referenced files ---';
|
||||
const FILE_REFERENCE_END = '--- End of content ---';
|
||||
const FILE_CONTENT_PREFIX = /^Content from @([^\n:]+):\n?/m;
|
||||
|
||||
/**
|
||||
* Parse content to identify file references and regular text
|
||||
* @param content - The raw content string
|
||||
* @returns Array of content segments
|
||||
*/
|
||||
export function parseContentWithFileReferences(
|
||||
content: string,
|
||||
): ContentSegment[] {
|
||||
const segments: ContentSegment[] = [];
|
||||
|
||||
// Find the file reference section
|
||||
const startIndex = content.indexOf(FILE_REFERENCE_START);
|
||||
const endIndex = content.indexOf(FILE_REFERENCE_END);
|
||||
|
||||
// No file reference section found
|
||||
if (startIndex === -1) {
|
||||
return [{ type: 'text', content }];
|
||||
}
|
||||
|
||||
// Add text before file references
|
||||
const textBefore = content.substring(0, startIndex).trim();
|
||||
if (textBefore) {
|
||||
segments.push({ type: 'text', content: textBefore });
|
||||
}
|
||||
|
||||
// Extract file reference section
|
||||
const fileRefSection =
|
||||
endIndex !== -1
|
||||
? content.substring(startIndex + FILE_REFERENCE_START.length, endIndex)
|
||||
: content.substring(startIndex + FILE_REFERENCE_START.length);
|
||||
|
||||
// Parse individual file references
|
||||
// Split by "Content from @" pattern
|
||||
const fileRefParts = fileRefSection.split(/(?=\nContent from @)/);
|
||||
|
||||
for (const part of fileRefParts) {
|
||||
const trimmedPart = part.trim();
|
||||
if (!trimmedPart) continue;
|
||||
|
||||
// Try to extract file path
|
||||
const match = trimmedPart.match(FILE_CONTENT_PREFIX);
|
||||
if (match) {
|
||||
const filePath = match[1].trim();
|
||||
const fileName = filePath.split('/').pop() || filePath;
|
||||
const fileContent = trimmedPart.substring(match[0].length);
|
||||
|
||||
segments.push({
|
||||
type: 'file_reference',
|
||||
content: fileContent.trim(),
|
||||
filePath,
|
||||
fileName,
|
||||
});
|
||||
} else if (trimmedPart && !trimmedPart.startsWith('Content from @')) {
|
||||
// This might be content without proper prefix, still treat as file reference
|
||||
segments.push({
|
||||
type: 'file_reference',
|
||||
content: trimmedPart,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add text after file references
|
||||
if (endIndex !== -1) {
|
||||
const textAfter = content
|
||||
.substring(endIndex + FILE_REFERENCE_END.length)
|
||||
.trim();
|
||||
if (textAfter) {
|
||||
segments.push({ type: 'text', content: textAfter });
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for CollapsibleFileReference
|
||||
*/
|
||||
interface CollapsibleFileReferenceProps {
|
||||
segment: ContentSegment;
|
||||
onFileClick?: (path: string) => void;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CollapsibleFileReference - A single collapsible file reference block
|
||||
*/
|
||||
const CollapsibleFileReference: FC<CollapsibleFileReferenceProps> = ({
|
||||
segment,
|
||||
onFileClick,
|
||||
defaultExpanded = false,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
const lineCount = useMemo(() => segment.content.split('\n').length, [segment.content]);
|
||||
|
||||
const handleToggle = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
const handleFileClick = () => {
|
||||
if (segment.filePath && onFileClick) {
|
||||
onFileClick(segment.filePath);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-md overflow-hidden"
|
||||
style={{
|
||||
border: '1px solid var(--app-input-border)',
|
||||
backgroundColor: 'var(--app-secondary-background)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 w-full py-1.5 px-2.5 bg-transparent border-none cursor-pointer text-left text-xs transition-colors duration-150 hover:bg-black/5"
|
||||
style={{ color: 'var(--app-secondary-foreground)' }}
|
||||
onClick={handleToggle}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<span
|
||||
className="text-[8px] flex-shrink-0 transition-transform duration-200"
|
||||
style={{
|
||||
color: 'var(--app-secondary-foreground)',
|
||||
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
}}
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
<span className="text-sm flex-shrink-0">📄</span>
|
||||
<span
|
||||
className="font-medium cursor-pointer overflow-hidden text-ellipsis whitespace-nowrap flex-1 min-w-0 hover:underline"
|
||||
style={{ color: 'var(--app-link-color, #0066cc)' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFileClick();
|
||||
}}
|
||||
title={segment.filePath}
|
||||
>
|
||||
{segment.fileName || 'Referenced file'}
|
||||
</span>
|
||||
<span
|
||||
className="text-[11px] flex-shrink-0 ml-auto"
|
||||
style={{ color: 'var(--app-tertiary-foreground, #999)' }}
|
||||
>
|
||||
{lineCount} {lineCount === 1 ? 'line' : 'lines'}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div
|
||||
className="py-2 px-2.5 max-h-[300px] overflow-y-auto text-xs leading-normal"
|
||||
style={{
|
||||
borderTop: '1px solid var(--app-input-border)',
|
||||
backgroundColor: 'var(--app-primary-background)',
|
||||
}}
|
||||
>
|
||||
<MessageContent
|
||||
content={segment.content}
|
||||
onFileClick={onFileClick}
|
||||
enableFileLinks={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Props for CollapsibleFileContent
|
||||
*/
|
||||
export interface CollapsibleFileContentProps {
|
||||
content: string;
|
||||
onFileClick?: (path: string) => void;
|
||||
enableFileLinks?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CollapsibleFileContent - Renders content with collapsible file references
|
||||
*
|
||||
* Detects file reference patterns in user messages and renders them as
|
||||
* collapsible blocks to improve readability.
|
||||
*/
|
||||
export const CollapsibleFileContent: FC<CollapsibleFileContentProps> = ({
|
||||
content,
|
||||
onFileClick,
|
||||
enableFileLinks = false,
|
||||
}) => {
|
||||
const segments = useMemo(
|
||||
() => parseContentWithFileReferences(content),
|
||||
[content],
|
||||
);
|
||||
|
||||
// If no file references found, render as normal content
|
||||
if (segments.length === 1 && segments[0].type === 'text') {
|
||||
return (
|
||||
<MessageContent
|
||||
content={content}
|
||||
onFileClick={onFileClick}
|
||||
enableFileLinks={enableFileLinks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.type === 'text') {
|
||||
return (
|
||||
<div key={index}>
|
||||
<MessageContent
|
||||
content={segment.content}
|
||||
onFileClick={onFileClick}
|
||||
enableFileLinks={enableFileLinks}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapsibleFileReference
|
||||
key={index}
|
||||
segment={segment}
|
||||
onFileClick={onFileClick}
|
||||
defaultExpanded={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollapsibleFileContent;
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import type { FC } from 'react';
|
||||
import { MessageContent } from './MessageContent.js';
|
||||
import { CollapsibleFileContent } from './CollapsibleFileContent.js';
|
||||
|
||||
export interface FileContext {
|
||||
fileName: string;
|
||||
|
|
@ -59,7 +59,7 @@ export const UserMessage: FC<UserMessageProps> = ({
|
|||
color: 'var(--app-primary-foreground)',
|
||||
}}
|
||||
>
|
||||
<MessageContent
|
||||
<CollapsibleFileContent
|
||||
content={content}
|
||||
onFileClick={onFileClick}
|
||||
enableFileLinks={false}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* Search tool call component - specialized for search operations
|
||||
*/
|
||||
|
||||
import type { FC } from 'react';
|
||||
import { useState, type FC } from 'react';
|
||||
import {
|
||||
safeTitle,
|
||||
groupContent,
|
||||
|
|
@ -16,6 +16,38 @@ import {
|
|||
import type { BaseToolCallProps, ContainerStatus } from './shared/index.js';
|
||||
import { FileLink } from '../layout/FileLink.js';
|
||||
|
||||
/**
|
||||
* Collapsible output component for search results
|
||||
* Shows a summary line that can be expanded to show full content
|
||||
*/
|
||||
const CollapsibleOutput: FC<{
|
||||
/** Summary text to show when collapsed (e.g., "21 lines of output") */
|
||||
summary: string;
|
||||
/** Content to show when expanded */
|
||||
children: React.ReactNode;
|
||||
/** Whether to start expanded (default: false) */
|
||||
defaultExpanded?: boolean;
|
||||
}> = ({ summary, children, defaultExpanded = false }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div
|
||||
className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1 cursor-pointer hover:opacity-100 transition-opacity"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
<span className="flex-shrink-0">{summary}</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="ml-4 mt-1 text-[var(--app-secondary-foreground)] text-[0.85em]">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Row component for search card layout
|
||||
*/
|
||||
|
|
@ -116,28 +148,8 @@ export const SearchToolCall: FC<BaseToolCallProps> = ({
|
|||
|
||||
// Success case with results: show search query + file list
|
||||
if (locations && locations.length > 0) {
|
||||
// Multiple results use card layout
|
||||
if (locations.length > 1) {
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label={displayLabel}
|
||||
labelSuffix={queryText}
|
||||
status={containerStatus}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
>
|
||||
<SearchCardContent>
|
||||
<SearchRow label={displayLabel}>
|
||||
<div className="font-mono">{queryText}</div>
|
||||
</SearchRow>
|
||||
<SearchRow label={`Found (${locations.length})`}>
|
||||
<LocationsListLocal locations={locations} />
|
||||
</SearchRow>
|
||||
</SearchCardContent>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
// Single result - compact format
|
||||
// Use collapsible output for multiple results
|
||||
const summaryText = `${locations.length} ${locations.length === 1 ? 'file' : 'files'} found`;
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label={displayLabel}
|
||||
|
|
@ -146,13 +158,22 @@ export const SearchToolCall: FC<BaseToolCallProps> = ({
|
|||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
>
|
||||
<LocationsListLocal locations={locations} />
|
||||
<CollapsibleOutput summary={summaryText}>
|
||||
<LocationsListLocal locations={locations} />
|
||||
</CollapsibleOutput>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Show content text if available
|
||||
// Show content text if available (e.g., grep output with content)
|
||||
if (textOutputs.length > 0) {
|
||||
// Count total lines in output
|
||||
const totalLines = textOutputs.reduce(
|
||||
(acc, text) => acc + text.split('\n').length,
|
||||
0,
|
||||
);
|
||||
const summaryText = `${totalLines} ${totalLines === 1 ? 'line' : 'lines'} of output`;
|
||||
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label={displayLabel}
|
||||
|
|
@ -161,17 +182,13 @@ export const SearchToolCall: FC<BaseToolCallProps> = ({
|
|||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{textOutputs.map((text: string, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1"
|
||||
>
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
<span className="flex-shrink-0 w-full">{text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<CollapsibleOutput summary={summaryText}>
|
||||
<div className="flex flex-col gap-1 font-mono text-[0.85em] whitespace-pre-wrap break-all">
|
||||
{textOutputs.map((text: string, index: number) => (
|
||||
<div key={index}>{text}</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleOutput>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,14 @@ export type {
|
|||
AssistantMessageProps,
|
||||
AssistantMessageStatus,
|
||||
} from './components/messages/Assistant/AssistantMessage';
|
||||
export {
|
||||
CollapsibleFileContent,
|
||||
parseContentWithFileReferences,
|
||||
} from './components/messages/CollapsibleFileContent';
|
||||
export type {
|
||||
CollapsibleFileContentProps,
|
||||
ContentSegment,
|
||||
} from './components/messages/CollapsibleFileContent';
|
||||
|
||||
// ChatViewer - standalone chat display component
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export default defineConfig({
|
|||
globals: {
|
||||
react: 'React',
|
||||
'react-dom': 'ReactDOM',
|
||||
'react/jsx-runtime': 'jsxRuntime',
|
||||
'react/jsx-runtime': 'ReactJSXRuntime',
|
||||
},
|
||||
assetFileNames: 'styles.[ext]',
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue