feat: Implement knowledge import feature for characters
- Added KnowledgeImport page for importing character knowledge from URLs. - Integrated URL validation and error handling for unsupported websites. - Created API endpoints for importing content from URLs and retrieving character knowledge. - Enhanced VectorStoreService with logging and error handling for vector memory storage. - Updated frontend to display knowledge sources and manage them effectively. - Added support for fetching recent character knowledge as a fallback in similarity searches. - Updated OpenAPI documentation to reflect new import functionality.
This commit is contained in:
@@ -6,6 +6,7 @@ import { CharacterList } from './pages/CharacterList';
|
||||
import { CharacterForm } from './pages/CharacterForm';
|
||||
import { ConversationList } from './pages/ConversationList';
|
||||
import { Chat } from './pages/Chat';
|
||||
import { KnowledgeImport } from './pages/KnowledgeImport';
|
||||
|
||||
// OAuth Callback Handler - processes tokens from URL before routing
|
||||
function OAuthCallbackHandler({ children }: { children: React.ReactNode }) {
|
||||
@@ -124,6 +125,15 @@ function App() {
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/characters/:characterId/knowledge"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<KnowledgeImport />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</OAuthCallbackHandler>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type {
|
||||
ImportControllerUploadFileBody
|
||||
ImportControllerUploadFileBody,
|
||||
ImportUrlDto,
|
||||
UploadResponseDto,
|
||||
} from '.././model';
|
||||
|
||||
import { customFetch } from '../../mutator/custom-fetch';
|
||||
@@ -109,3 +111,29 @@ export const importControllerDeleteKnowledge = async (knowledgeId: string, optio
|
||||
);}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @summary Import content from URL for character knowledge
|
||||
*/
|
||||
export const getImportControllerImportFromUrlUrl = (characterId: string,) => {
|
||||
|
||||
|
||||
|
||||
|
||||
return `http://localhost:3000/api/import/characters/${characterId}/url`
|
||||
}
|
||||
|
||||
export const importControllerImportFromUrl = async (characterId: string,
|
||||
importUrlDto: ImportUrlDto, options?: RequestInit): Promise<UploadResponseDto> => {
|
||||
|
||||
return customFetch<UploadResponseDto>(getImportControllerImportFromUrlUrl(characterId),
|
||||
{
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...options?.headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(importUrlDto),
|
||||
}
|
||||
);}
|
||||
|
||||
12
apps/frontend/src/api/generated/model/importUrlDto.ts
Normal file
12
apps/frontend/src/api/generated/model/importUrlDto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.4.2 🍺
|
||||
* Do not edit manually.
|
||||
* DreamChat API
|
||||
* The DreamChat API documentation
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface ImportUrlDto {
|
||||
/** URL to import */
|
||||
url: string;
|
||||
}
|
||||
@@ -20,7 +20,9 @@ export * from './createCharacterDtoAttributes';
|
||||
export * from './createCharacterDtoConfig';
|
||||
export * from './createConversationDto';
|
||||
export * from './importControllerUploadFileBody';
|
||||
export * from './importUrlDto';
|
||||
export * from './keycloakConfigDto';
|
||||
export * from './uploadResponseDto';
|
||||
export * from './keycloakLoginUrlDto';
|
||||
export * from './loginDto';
|
||||
export * from './messageResponseDto';
|
||||
|
||||
14
apps/frontend/src/api/generated/model/uploadResponseDto.ts
Normal file
14
apps/frontend/src/api/generated/model/uploadResponseDto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by orval v8.4.2 🍺
|
||||
* Do not edit manually.
|
||||
* DreamChat API
|
||||
* The DreamChat API documentation
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface UploadResponseDto {
|
||||
/** Knowledge ID */
|
||||
knowledgeId: string;
|
||||
/** Status message */
|
||||
message: string;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||
import { useCharacterStore } from '../stores/characterStore';
|
||||
import { importControllerGetCharacterKnowledge } from '../api/generated/import/import';
|
||||
|
||||
export function CharacterForm() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -23,10 +24,17 @@ export function CharacterForm() {
|
||||
const [avatarUrl, setAvatarUrl] = useState('');
|
||||
const [isPublic, setIsPublic] = useState(false);
|
||||
const [attributes, setAttributes] = useState('{}');
|
||||
const [knowledgeCount, setKnowledgeCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && id) {
|
||||
getCharacter(id);
|
||||
// Fetch knowledge count
|
||||
importControllerGetCharacterKnowledge(id).then((knowledge: any) => {
|
||||
setKnowledgeCount(knowledge?.length || 0);
|
||||
}).catch(() => {
|
||||
setKnowledgeCount(0);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
setCurrentCharacter(null);
|
||||
@@ -174,6 +182,28 @@ export function CharacterForm() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isEditing && id && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">Knowledge Sources</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{knowledgeCount === 0
|
||||
? 'No knowledge imported yet'
|
||||
: `${knowledgeCount} knowledge source${knowledgeCount === 1 ? '' : 's'} imported`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to={`/characters/${id}/knowledge`}
|
||||
className="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
{knowledgeCount === 0 ? 'Import Knowledge' : 'Manage Knowledge'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCharacterStore } from '../stores/characterStore';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import type { Message } from '../types';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { chatControllerSendMessage } from '../api/generated/conversations/conversations';
|
||||
|
||||
const WS_URL = (import.meta.env as unknown as ImportMetaEnv).VITE_WS_URL || 'http://localhost:3000';
|
||||
|
||||
@@ -26,6 +27,7 @@ export function Chat() {
|
||||
|
||||
const [message, setMessage] = useState('');
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const [socketConnected, setSocketConnected] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
@@ -42,6 +44,17 @@ export function Chat() {
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected to chat server');
|
||||
setSocketConnected(true);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('Disconnected from chat server');
|
||||
setSocketConnected(false);
|
||||
});
|
||||
|
||||
socket.on('connect_error', (err) => {
|
||||
console.error('Socket connection error:', err.message);
|
||||
setSocketConnected(false);
|
||||
});
|
||||
|
||||
socket.on('message_chunk', (data: { conversationId: string; chunk: { content: string } }) => {
|
||||
@@ -111,11 +124,30 @@ export function Chat() {
|
||||
setStreamingContent('');
|
||||
setStreaming(true);
|
||||
|
||||
// Send via socket for streaming
|
||||
socketRef.current?.emit('send_message', {
|
||||
conversationId,
|
||||
content,
|
||||
});
|
||||
// Check if socket is connected, otherwise fallback to HTTP
|
||||
if (socketRef.current?.connected) {
|
||||
socketRef.current.emit('send_message', {
|
||||
conversationId,
|
||||
content,
|
||||
});
|
||||
} else {
|
||||
// Fallback to HTTP API when socket is not connected
|
||||
try {
|
||||
const result = await chatControllerSendMessage(conversationId, { content });
|
||||
|
||||
// Add messages to the conversation
|
||||
if (result.userMessage) {
|
||||
addMessage(result.userMessage as Message);
|
||||
}
|
||||
if (result.assistantMessage) {
|
||||
addMessage(result.assistantMessage as Message);
|
||||
}
|
||||
setStreaming(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
setStreaming(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
|
||||
383
apps/frontend/src/pages/KnowledgeImport.tsx
Normal file
383
apps/frontend/src/pages/KnowledgeImport.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useCharacterStore } from '../stores/characterStore';
|
||||
import { importControllerImportFromUrl, importControllerDeleteKnowledge } from '../api/generated/import/import';
|
||||
|
||||
interface KnowledgeItem {
|
||||
id: string;
|
||||
name: string;
|
||||
sourceType: 'file' | 'url' | 'manual';
|
||||
sourceName: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
createdAt: string;
|
||||
processingInfo?: {
|
||||
error?: string;
|
||||
chunksProcessed?: number;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
export function KnowledgeImport() {
|
||||
const { characterId } = useParams<{ characterId: string }>();
|
||||
const { currentCharacter, getCharacter } = useCharacterStore();
|
||||
|
||||
const [url, setUrl] = useState('');
|
||||
const [knowledgeList, setKnowledgeList] = useState<KnowledgeItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
// Supported URL patterns for user reference
|
||||
const supportedPatterns = [
|
||||
{ label: 'Sakurazaka46 Blog', pattern: 'sakurazaka46.com/s/s46/diary/detail/' },
|
||||
];
|
||||
|
||||
const fetchKnowledge = useCallback(async () => {
|
||||
if (!characterId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`http://localhost:3000/api/import/characters/${characterId}/knowledge`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setKnowledgeList(data);
|
||||
}
|
||||
} catch {
|
||||
// Silent fail - we'll show empty state
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [characterId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (characterId) {
|
||||
getCharacter(characterId);
|
||||
fetchKnowledge();
|
||||
}
|
||||
}, [characterId, getCharacter, fetchKnowledge]);
|
||||
|
||||
const validateUrl = (inputUrl: string): { valid: boolean; message?: string } => {
|
||||
try {
|
||||
const urlObj = new URL(inputUrl);
|
||||
|
||||
// Check if it's a supported domain
|
||||
const supportedDomains = ['sakurazaka46.com', 'www.sakurazaka46.com'];
|
||||
if (!supportedDomains.includes(urlObj.hostname)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Unsupported website: ${urlObj.hostname}. Currently only Sakurazaka46 blogs are supported.`
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's a blog detail page
|
||||
if (!urlObj.pathname.includes('/diary/detail/')) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'URL does not appear to be a valid blog post. Please use a blog detail URL.'
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
} catch {
|
||||
return { valid: false, message: 'Invalid URL format' };
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
if (!characterId) {
|
||||
setError('Character ID is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!url.trim()) {
|
||||
setError('Please enter a URL');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate URL before attempting import
|
||||
const validation = validateUrl(url.trim());
|
||||
if (!validation.valid) {
|
||||
setError(validation.message || 'Invalid URL');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const result = await importControllerImportFromUrl(characterId, { url: url.trim() });
|
||||
|
||||
setSuccessMessage(`Import started! Knowledge ID: ${result.knowledgeId}`);
|
||||
setUrl('');
|
||||
|
||||
// Refresh the knowledge list
|
||||
await fetchKnowledge();
|
||||
|
||||
// Clear success message after 5 seconds
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} catch (err: any) {
|
||||
// Handle specific error messages from the backend
|
||||
const errorMessage = err.message || 'Failed to import URL';
|
||||
|
||||
if (errorMessage.includes('Unsupported URL')) {
|
||||
setError('This website is not supported for import. Currently only Sakurazaka46 blogs are supported.');
|
||||
} else if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Failed to scrape')) {
|
||||
setError('Could not fetch content from the URL. Please check the URL is correct and accessible.');
|
||||
} else if (errorMessage.includes('Could not find article')) {
|
||||
setError('Could not find blog content on the page. Please check this is a valid blog post URL.');
|
||||
} else {
|
||||
setError(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (knowledgeId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this knowledge?')) return;
|
||||
|
||||
try {
|
||||
await importControllerDeleteKnowledge(knowledgeId);
|
||||
await fetchKnowledge();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to delete knowledge');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'bg-green-100 text-green-800';
|
||||
case 'processing': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'failed': return 'bg-red-100 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed': return '✓';
|
||||
case 'processing': return '⟳';
|
||||
case 'failed': return '✗';
|
||||
default: return '○';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const truncateUrl = (url: string, maxLength: number = 50) => {
|
||||
if (url.length <= maxLength) return url;
|
||||
return url.substring(0, maxLength) + '...';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link to={`/characters/${characterId}`} className="text-gray-600 hover:text-gray-900">
|
||||
← Back to Character
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold text-gray-900">
|
||||
Knowledge Import
|
||||
{currentCharacter && (
|
||||
<span className="text-gray-500 font-normal"> - {currentCharacter.name}</span>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Left column - Import form */}
|
||||
<div className="space-y-6">
|
||||
{/* URL Import Card */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Import from URL
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 p-3 rounded">
|
||||
<div className="flex items-start">
|
||||
<span className="mr-2">⚠️</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<div className="mb-4 bg-green-50 border border-green-200 text-green-700 p-3 rounded">
|
||||
<div className="flex items-start">
|
||||
<span className="mr-2">✅</span>
|
||||
<span>{successMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="url" className="block text-sm font-medium text-gray-700">
|
||||
Blog URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://sakurazaka46.com/s/s46/diary/detail/68008"
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Enter a Sakurazaka46 blog URL to import as character knowledge.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isImporting || !url.trim()}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isImporting ? (
|
||||
<span className="flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Importing...
|
||||
</span>
|
||||
) : (
|
||||
'Import from URL'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Supported Sites Card */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-md font-medium text-gray-900 mb-3">
|
||||
Supported Websites
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{supportedPatterns.map((pattern, index) => (
|
||||
<li key={index} className="flex items-center text-sm text-gray-600">
|
||||
<span className="w-2 h-2 bg-green-400 rounded-full mr-2"></span>
|
||||
<span className="font-medium">{pattern.label}</span>
|
||||
<code className="ml-2 px-2 py-0.5 bg-gray-100 rounded text-xs">
|
||||
{pattern.pattern}
|
||||
</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-3 text-xs text-gray-500">
|
||||
More websites will be supported in future updates.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column - Knowledge list */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Knowledge Sources
|
||||
</h2>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p className="mt-2 text-sm text-gray-500">Loading...</p>
|
||||
</div>
|
||||
) : knowledgeList.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg">
|
||||
<p className="text-gray-500">No knowledge sources yet.</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Import a blog URL to add knowledge to this character.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 max-h-[600px] overflow-y-auto">
|
||||
{knowledgeList.map((knowledge) => (
|
||||
<div
|
||||
key={knowledge.id}
|
||||
className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${getStatusColor(knowledge.status)}`}>
|
||||
<span className="mr-1">{getStatusIcon(knowledge.status)}</span>
|
||||
{knowledge.status.charAt(0).toUpperCase() + knowledge.status.slice(1)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{knowledge.sourceType === 'url' ? '🔗' : '📄'} {knowledge.sourceType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900 truncate">
|
||||
{knowledge.name}
|
||||
</h3>
|
||||
|
||||
{knowledge.sourceType === 'url' && (
|
||||
<p className="mt-1 text-xs text-gray-500 truncate" title={knowledge.sourceName}>
|
||||
{truncateUrl(knowledge.sourceName)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
{formatDate(knowledge.createdAt)}
|
||||
</p>
|
||||
|
||||
{knowledge.status === 'processing' && (
|
||||
<p className="mt-2 text-xs text-yellow-600">
|
||||
Processing content... This may take a moment.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{knowledge.status === 'failed' && knowledge.processingInfo?.error && (
|
||||
<p className="mt-2 text-xs text-red-600">
|
||||
Error: {knowledge.processingInfo.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{knowledge.status === 'completed' && knowledge.processingInfo?.chunksProcessed && (
|
||||
<p className="mt-2 text-xs text-green-600">
|
||||
✓ Processed {knowledge.processingInfo.chunksProcessed} chunks
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleDelete(knowledge.id)}
|
||||
className="ml-4 text-red-600 hover:text-red-800 text-sm"
|
||||
title="Delete knowledge"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user