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:
GW_MC
2026-02-24 14:29:26 +00:00
parent 8714d6bd22
commit e033d67ec1
30 changed files with 2018 additions and 204 deletions

View File

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

View File

@@ -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),
}
);}

View 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;
}

View File

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

View 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;
}

View File

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

View File

@@ -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 = () => {

View 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>
);
}