feat: implement QuestionBank CRUD with pagination and template query

- Add pagination support to findAll (page, limit query params)
- Add findByTemplateId method to service
- Add GET /by-template/:templateId endpoint to controller
- Service already includes CRUD for QuestionBank and QuestionBankItem
This commit is contained in:
Developer
2026-04-23 17:19:11 +08:00
commit 0a9588abb7
492 changed files with 112453 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
import { API_BASE_URL } from '../utils/constants';
interface ApiResponse<T = any> {
data: T;
status: number;
}
class ApiClient {
private baseURL: string;
constructor(baseURL: string) {
this.baseURL = baseURL;
}
private getAuthHeaders(): Record<string, string> {
const apiKey = localStorage.getItem('kb_api_key');
const activeTenantId = localStorage.getItem('kb_active_tenant_id');
const token = localStorage.getItem('authToken') || localStorage.getItem('token');
const language = localStorage.getItem('userLanguage') || 'zh';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-user-language': language,
};
if (apiKey) {
if (apiKey.startsWith('kb_')) {
headers['x-api-key'] = apiKey;
} else {
headers['Authorization'] = `Bearer ${apiKey}`;
}
} else if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (activeTenantId && activeTenantId !== 'undefined' && activeTenantId !== 'null') {
headers['x-tenant-id'] = activeTenantId;
}
return headers;
}
private async handleResponse<T>(response: Response): Promise<ApiResponse<T>> {
const text = await response.text();
let data: any;
try {
data = text ? JSON.parse(text) : null;
} catch (e) {
data = null;
}
if (!response.ok) {
throw new Error(data?.message || text || 'Request failed');
}
return { data: data as T, status: response.status };
}
// 新しい API 呼び出し方法、{ data, status } を返す
async get<T = any>(url: string): Promise<ApiResponse<T>> {
const response = await fetch(`${this.baseURL}${url}`, {
method: 'GET',
headers: this.getAuthHeaders(),
});
return this.handleResponse<T>(response);
}
async post<T = any>(url: string, body?: any): Promise<ApiResponse<T>> {
const response = await fetch(`${this.baseURL}${url}`, {
method: 'POST',
headers: this.getAuthHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
return this.handleResponse<T>(response);
}
async put<T = any>(url: string, body?: any): Promise<ApiResponse<T>> {
const response = await fetch(`${this.baseURL}${url}`, {
method: 'PUT',
headers: this.getAuthHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
return this.handleResponse<T>(response);
}
async patch<T = any>(url: string, body?: any): Promise<ApiResponse<T>> {
const response = await fetch(`${this.baseURL}${url}`, {
method: 'PATCH',
headers: this.getAuthHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
return this.handleResponse<T>(response);
}
async delete<T = any>(url: string): Promise<ApiResponse<T>> {
const response = await fetch(`${this.baseURL}${url}`, {
method: 'DELETE',
headers: this.getAuthHeaders(),
});
return this.handleResponse<T>(response);
}
// New methods for special formats
async getBlob(url: string): Promise<Blob> {
const response = await fetch(`${this.baseURL}${url}`, {
method: 'GET',
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new Error('Request failed');
}
return await response.blob();
}
async postMultipart<T = any>(url: string, formData: FormData): Promise<ApiResponse<T>> {
const headers = this.getAuthHeaders();
// Remove Content-Type to let the browser set it with the correct boundary
delete headers['Content-Type'];
const response = await fetch(`${this.baseURL}${url}`, {
method: 'POST',
headers,
body: formData,
});
return this.handleResponse<T>(response);
}
// Legacy compatibility method — returns raw Response for streaming and other special cases
async request(path: string, options: RequestInit = {}): Promise<Response> {
const authHeaders = this.getAuthHeaders();
const headers = new Headers(options.headers);
// Merge auth headers into request headers
Object.entries(authHeaders).forEach(([key, value]) => {
// Don't override if already set
if (headers.has(key)) return;
// Don't set Content-Type if body is FormData (let browser set it with boundary)
if (key === 'Content-Type' && options.body instanceof FormData) return;
headers.set(key, value);
});
let url = path;
if (!path.startsWith('http')) {
const cleanPath = path.startsWith('/') ? path : `/${path}`;
url = `${this.baseURL}${cleanPath}`;
}
const response = await fetch(url, {
...options,
headers,
});
if (response.status === 401) {
localStorage.removeItem('kb_api_key');
localStorage.removeItem('authToken');
window.location.href = '/login';
throw new Error('Unauthorized');
}
return response;
}
private handleUnauthorized() {
console.warn('[ApiClient] 401 Unauthorized detected. Cleaning up and redirecting to login...');
localStorage.removeItem('kb_api_key');
localStorage.removeItem('authToken');
localStorage.removeItem('token');
localStorage.removeItem('kb_active_tenant_id');
// Only redirect if we are not already on the login page
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
}
export const apiClient = new ApiClient(API_BASE_URL);
+99
View File
@@ -0,0 +1,99 @@
import { apiClient } from './apiClient';
export interface AssessmentSession {
id: string;
userId: string;
knowledgeBaseId: string;
threadId: string;
status: 'IN_PROGRESS' | 'COMPLETED';
finalScore?: number;
finalReport?: string;
createdAt: string;
updatedAt: string;
knowledgeBase?: { id: string; name: string };
knowledgeGroup?: { id: string; name: string };
}
export interface AssessmentState {
messages: any[];
assessmentSessionId: string;
knowledgeBaseId: string;
questions: any[];
currentQuestionIndex: number;
shouldFollowUp: boolean;
scores: Record<string, number>;
feedbackHistory?: any[];
status?: 'IN_PROGRESS' | 'COMPLETED';
report?: string;
finalScore?: number;
}
export class AssessmentService {
async startSession(knowledgeBaseId: string, language: string, templateId?: string): Promise<AssessmentSession> {
const { data } = await apiClient.post<AssessmentSession>('/assessment/start', { knowledgeBaseId, language, templateId });
return data;
}
async submitAnswer(sessionId: string, answer: string, language: string): Promise<AssessmentState> {
const { data } = await apiClient.post<AssessmentState>(`/assessment/${sessionId}/answer`, { answer, language });
return data;
}
async getSessionState(sessionId: string): Promise<AssessmentState> {
const { data } = await apiClient.get<AssessmentState>(`/assessment/${sessionId}/state`);
return data;
}
async getHistory(): Promise<AssessmentSession[]> {
const { data } = await apiClient.get<AssessmentSession[]>('/assessment');
return data;
}
async deleteSession(sessionId: string): Promise<void> {
await apiClient.delete(`/assessment/${sessionId}`);
}
async *startSessionStream(sessionId: string, templateId?: string): AsyncIterableIterator<any> {
const query = templateId ? `?templateId=${templateId}` : '';
const response = await apiClient.request(`/assessment/${sessionId}/start-stream${query}`, {
method: 'GET',
});
yield* this.parseStream(response);
}
async *submitAnswerStream(sessionId: string, answer: string, language: string, templateId?: string): AsyncIterableIterator<any> {
const query = new URLSearchParams({ answer, language, ...(templateId && { templateId }) }).toString();
const response = await apiClient.request(`/assessment/${sessionId}/answer-stream?${query}`, {
method: 'GET',
});
yield* this.parseStream(response);
}
private async *parseStream(response: Response): AsyncIterableIterator<any> {
const reader = response.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.substring(6));
yield data;
} catch (e) {
console.error('Failed to parse SSE data:', line);
}
}
}
}
}
}
export const assessmentService = new AssessmentService();
+45
View File
@@ -0,0 +1,45 @@
import { API_BASE_URL } from '../utils/constants';
import { apiClient } from './apiClient';
interface AuthResponse {
access_token: string;
}
export const authService = {
async login(username: string, password: string): Promise<AuthResponse> {
const language = localStorage.getItem('userLanguage') || 'ja';
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-language': language,
},
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Login failed');
}
return response.json();
},
async getProfile(token: string): Promise<any> {
const response = await apiClient.request('/auth/profile', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to fetch profile');
}
return response.json();
},
};
+166
View File
@@ -0,0 +1,166 @@
import { apiClient } from './apiClient';
export interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
export interface ChatSource {
fileName: string;
title?: string;
content: string;
score: number;
chunkIndex: number;
fileId?: string;
}
export class ChatService {
async *streamChat(
message: string,
history: ChatMessage[],
authToken: string,
userLanguage: string = 'zh',
selectedEmbeddingId?: string,
selectedLLMId?: string, // Added: Selected LLM ID
selectedGroups?: string[], // Added: Selected groups
selectedFiles?: string[], // Added: Selected files
historyId?: string, // Added: Conversation history ID
enableRerank?: boolean, // Added: Enable Rerank
selectedRerankId?: string, // Added: Rerank model ID
temperature?: number, // Added: temperature parameter
maxTokens?: number, // Added: maxTokens parameter
topK?: number, // Added: topK parameter
similarityThreshold?: number, // Added: similarityThreshold parameter
rerankSimilarityThreshold?: number, // Added: rerankSimilarityThreshold parameter
enableQueryExpansion?: boolean, // Added
enableHyDE?: boolean // Added
): AsyncGenerator<{ type: 'content' | 'sources' | 'error' | 'historyId'; data: any }> {
try {
const response = await apiClient.request('/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-language': userLanguage || localStorage.getItem('userLanguage') || 'zh',
},
body: JSON.stringify({
message,
history,
userLanguage,
selectedEmbeddingId,
selectedLLMId,
selectedGroups,
selectedFiles,
historyId,
enableRerank,
selectedRerankId,
temperature,
maxTokens,
topK,
similarityThreshold,
rerankSimilarityThreshold,
enableQueryExpansion,
enableHyDE
}),
});
if (!response.ok) {
let errorMessage = 'Request failed';
try {
const error = await response.json();
errorMessage = error.error || error.message || 'Request failed';
} catch {
errorMessage = `Server error: ${response.status}`;
}
yield { type: 'error', data: errorMessage };
return;
}
const reader = response.body?.getReader();
if (!reader) {
yield { type: 'error', data: 'Cannot read response stream' };
return;
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
return;
}
try {
const parsed = JSON.parse(data);
yield parsed;
} catch (e) {
console.warn('Failed to parse SSE data:', data);
}
}
}
}
} catch (error: any) {
yield { type: 'error', data: error.message || 'Network error' };
}
}
async *streamAssist(
instruction: string,
context: string,
authToken: string
): AsyncGenerator<{ type: 'content' | 'error'; data: any }> {
try {
const response = await apiClient.request('/chat/assist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-language': localStorage.getItem('userLanguage') || 'zh',
},
body: JSON.stringify({ instruction, context }),
});
if (!response.ok) {
yield { type: 'error', data: 'Request failed' };
return;
}
const reader = response.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') return;
try {
yield JSON.parse(data);
} catch (e) { console.warn(e) }
}
}
}
} catch (error: any) {
yield { type: 'error', data: error.message };
}
}
}
export const chatService = new ChatService();
+37
View File
@@ -0,0 +1,37 @@
import { apiClient } from './apiClient';
export interface ChunkConfigLimits {
maxChunkSize: number; // Max chunk size (tokens)
maxOverlapSize: number; // Max overlap size (tokens)
minOverlapSize: number; // Min overlap size (tokens)
defaultChunkSize: number; // Default chunk size
defaultOverlapSize: number; // Default overlap size
modelInfo: EmbeddingModelLimit; // Model info
}
export interface EmbeddingModelLimit {
name: string; // Model name
maxInputTokens: number; // Model input limit
maxBatchSize: number; // Model batch limit
expectedDimensions: number; // Expected vector dimensions
}
export const chunkConfigService = {
formatLimits(limits: ChunkConfigLimits): string {
return [
`Model: ${limits.modelInfo.name}`,
`Max Chunk: ${limits.maxChunkSize} tokens`,
`Max Overlap: ${limits.maxOverlapSize} tokens`,
`Batch Limit: ${limits.modelInfo.maxBatchSize}`,
`Vector Dimensions: ${limits.modelInfo.expectedDimensions}`,
].join(' | ');
},
async getLimits(embeddingModelId: string, token?: string): Promise<ChunkConfigLimits> {
const response = await apiClient.get<ChunkConfigLimits>(
`/knowledge-bases/chunk-config/limits?embeddingModelId=${embeddingModelId}`
);
return response.data;
},
};
+195
View File
@@ -0,0 +1,195 @@
import { KnowledgeFile, Message, Role, Language, AppSettings, ModelConfig } from "../types";
import { ragService } from './ragService';
const buildSystemInstruction = (files: KnowledgeFile[], settings: AppSettings, langInstruction: string) => {
const fileNames = files.map(f => f.name).join(", ");
return `
You are an intelligent knowledge base assistant operating as a RAG system.
System Configuration:
- Retrieval: Top ${settings.topK} chunks, Rerank: ${settings.enableRerank ? 'Enabled' : 'Disabled'}
- Knowledge Base Files: ${fileNames}
You have access to the content of these files in your context.
Your goal is to answer user questions primarily based on the content of these files.
Strict Citation Rules:
1. Whenever you use information from a file, you MUST cite the source filename at the end of the sentence or paragraph.
2. Citation format: [filename.extension].
3. If the answer is not found in the files, state so clearly.
4. ${langInstruction}
`;
};
// --- OpenAI Compatible Implementation ---
const callOpenAICompatible = async (
currentPrompt: string,
files: KnowledgeFile[],
history: Message[],
modelConfig: ModelConfig,
settings: AppSettings,
systemInstruction: string,
apiKey: string
): Promise<string> => {
if (!modelConfig.baseUrl) throw new Error("Base URL is required");
// Construct OpenAI format messages
const messages: any[] = [
{ role: "system", content: systemInstruction }
];
// Add history
history.forEach(msg => {
messages.push({
role: msg.role === Role.USER ? "user" : "assistant",
content: msg.text
});
});
// Current User Message Construction (Supports Vision)
const contentParts: any[] = [];
// 1. Add Text Prompt
contentParts.push({ type: "text", text: currentPrompt });
// 2. Add Images/Files
files.forEach((file) => {
if (file.type.startsWith("image/") && modelConfig.type === "vision") {
contentParts.push({
type: "image_url",
image_url: {
url: `data:${file.type};base64,${file.content}`
}
});
} else {
// For non-image files or if vision is not supported, append as text
try {
const decodedText = atob(file.content);
contentParts.push({
type: "text",
text: `\n--- Context from file: ${file.name} ---\n${decodedText.substring(0, 10000)}... (truncated)\n`
});
} catch (e) {
contentParts.push({
type: "text",
text: `\n[File attached: ${file.name} (${file.type}) - Content processing skipped]\n`
});
}
}
});
messages.push({ role: "user", content: contentParts });
const response = await fetch(`${modelConfig.baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`
},
body: JSON.stringify({
model: modelConfig.modelId,
messages: messages,
temperature: settings.temperature,
max_tokens: settings.maxTokens,
stream: false
})
});
if (!response.ok) {
const err = await response.text();
throw new Error(`API Error: ${response.status} - ${err}`);
}
const data = await response.json();
return data.choices?.[0]?.message?.content || "NO_RESPONSE_TEXT";
};
export const generateResponse = async (
currentPrompt: string,
files: KnowledgeFile[],
history: Message[],
language: Language,
modelConfig: ModelConfig,
settings: AppSettings,
authToken?: string,
onSearchStart?: () => void,
onSearchComplete?: (results: any) => void
): Promise<string> => {
// 1. Resolve API Key: Use model specific key
let apiKey = modelConfig.apiKey;
console.log('Model config:', modelConfig);
console.log('API Key present:', !!apiKey);
const langInstructionMap: Record<Language, string> = {
zh: "请始终使用Chinese回答。",
en: "Please always answer in English.",
ja: "常にJapaneseで答えてください。"
};
const langInstruction = langInstructionMap[language];
// RAG search (when knowledge base files exist)
let ragPrompt = currentPrompt;
let ragSources: string[] = [];
if (files.length > 0 && authToken) {
try {
onSearchStart?.();
console.log('Starting RAG search with prompt:', currentPrompt);
const ragResponse = await ragService.search(currentPrompt, {
...settings,
language
}, authToken);
console.log('RAG search response:', ragResponse);
if (ragResponse && ragResponse.hasRelevantContent) {
ragPrompt = ragResponse.ragPrompt;
ragSources = ragResponse.sources;
console.log('Using RAG enhanced prompt');
} else {
console.log('No relevant content found, using original prompt');
}
onSearchComplete?.(ragResponse?.searchResults || []);
} catch (error) {
console.warn('RAG search failed, using original prompt:', error);
onSearchComplete?.([]);
} finally {
// Ensure search status is reset
setTimeout(() => {
onSearchComplete?.([]);
}, 100);
}
}
const systemInstruction = buildSystemInstruction(files, settings, langInstruction);
try {
// API key is optional - allow local models
// --- OpenAI Compatible API Logic ---
return await callOpenAICompatible(
ragPrompt,
files,
history,
modelConfig,
settings,
systemInstruction,
apiKey || "ollama",
);
} catch (error: any) {
console.error("AI Service Error:", error);
// Provide more detailed error information
if (error.name === 'TypeError' && error.message.includes('fetch')) {
throw new Error('Network connection failed. Please check server status');
}
throw new Error(error.message || "API_ERROR");
}
};
+59
View File
@@ -0,0 +1,59 @@
import { apiClient } from './apiClient';
export interface ImportTask {
id: string;
sourcePath: string;
targetGroupId?: string;
targetGroupName?: string;
scheduledAt?: string;
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
logs?: string;
embeddingModelId?: string;
chunkSize?: number;
chunkOverlap?: number;
mode?: string;
createdAt: string;
}
export const importService = {
create: async (authToken: string, data: {
sourcePath: string;
targetGroupId?: string;
targetGroupName?: string;
embeddingModelId: string;
scheduledAt?: string;
chunkSize?: number;
chunkOverlap?: number;
mode?: string;
}): Promise<ImportTask> => {
const response = await apiClient.request('/import-tasks', {
method: 'POST',
body: JSON.stringify(data)
});
if (!response.ok) throw new Error('Failed to create import task');
return response.json();
},
getAll: async (authToken: string, options: { page?: number; limit?: number } = {}): Promise<{ items: ImportTask[]; total: number; page: number; limit: number }> => {
const queryParams = new URLSearchParams();
if (options.page) queryParams.append('page', options.page.toString());
if (options.limit) queryParams.append('limit', options.limit.toString());
const queryString = queryParams.toString();
const url = `/import-tasks${queryString ? `?${queryString}` : ''}`;
const response = await apiClient.request(url, {});
if (!response.ok) throw new Error('Failed to fetch import tasks');
return response.json();
},
delete: async (token: string, id: string): Promise<void> => {
const response = await fetch(`${API_BASE_URL}/import-tasks/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('Failed to delete import task');
}
};
+157
View File
@@ -0,0 +1,157 @@
import { apiClient } from './apiClient';
import { KnowledgeFile } from '../types';
export const knowledgeBaseService = {
async getAll(
authToken: string,
options: {
page?: number;
limit?: number;
name?: string;
status?: string;
groupId?: string;
} = {}
): Promise<{ items: KnowledgeFile[]; total: number; page: number; limit: number }> {
const queryParams = new URLSearchParams();
if (options.page) queryParams.append('page', options.page.toString());
if (options.limit) queryParams.append('limit', options.limit.toString());
if (options.name) queryParams.append('name', options.name);
if (options.status) queryParams.append('status', options.status);
if (options.groupId) queryParams.append('groupId', options.groupId);
const queryString = queryParams.toString();
const url = `/knowledge-bases${queryString ? `?${queryString}` : ''}`;
const response = await apiClient.request(url, {});
if (!response.ok) {
throw new Error('Failed to fetch knowledge base files');
}
const data = await response.json();
console.log('Knowledge base API response:', data);
const items = Array.isArray(data) ? data : (data.items || []);
const total = Array.isArray(data) ? data.length : (data.total || 0);
const page = Array.isArray(data) ? 1 : (data.page || 1);
const limit = Array.isArray(data) ? items.length : (data.limit || 12);
return {
items: items.map((item: any) => ({
id: item.id,
name: item.originalName,
originalName: item.originalName,
type: item.mimetype,
size: item.size,
status: item.status,
groups: item.groups || [],
createdAt: item.createdAt,
updatedAt: item.updatedAt,
})),
total,
page,
limit,
};
},
async getStatuses(ids: string[], authToken: string): Promise<{ id: string, status: KnowledgeFile['status'], updatedAt: string }[]> {
const response = await apiClient.request('/knowledge-bases/statuses', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ ids }),
});
if (!response.ok) {
throw new Error('Failed to fetch knowledge base statuses');
}
return response.json();
},
async getStats(authToken: string): Promise<{ total: number, uncategorized: number }> {
const response = await apiClient.request('/knowledge-bases/stats', {
method: 'GET'
});
if (!response.ok) {
throw new Error('Failed to fetch knowledge base stats');
}
return response.json();
},
async clearAll(authToken: string): Promise<void> {
const response = await apiClient.request('/knowledge-bases/clear', {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to clear knowledge base');
}
},
async search(query: string, topK: number = 5, authToken: string): Promise<any> {
const response = await apiClient.request('/knowledge-bases/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, topK }),
});
if (!response.ok) {
throw new Error('Failed to search knowledge base');
}
return response.json();
},
async deleteFile(fileId: string, authToken: string): Promise<void> {
const response = await apiClient.request(`/knowledge-bases/${fileId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete file');
}
},
async retryFile(fileId: string, authToken: string): Promise<KnowledgeFile> {
const response = await apiClient.request(`/knowledge-bases/${fileId}/retry`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to retry file');
}
const item = await response.json();
return {
id: item.id,
name: item.originalName,
originalName: item.originalName,
type: item.mimetype,
size: item.size,
status: item.status,
groups: item.groups || [],
createdAt: item.createdAt,
updatedAt: item.updatedAt,
};
},
async getFileChunks(fileId: string, authToken: string) {
const response = await apiClient.request(`/knowledge-bases/${fileId}/chunks`, {});
if (!response.ok) {
throw new Error('Failed to get file chunks');
}
return response.json();
},
getPageImageUrl(fileId: string, pageIndex: number): string {
return `/api/knowledge-bases/${fileId}/page/${pageIndex}`;
},
};
+69
View File
@@ -0,0 +1,69 @@
import { KnowledgeGroup, CreateGroupData, UpdateGroupData } from '../types';
import { apiClient } from './apiClient';
export const knowledgeGroupService = {
// Fetch all groups
async getGroups(options: { flat?: boolean; page?: number; limit?: number; name?: string } = {}): Promise<any> {
const queryParams = new URLSearchParams();
if (options.flat) queryParams.append('flat', 'true');
if (options.page) queryParams.append('page', options.page.toString());
if (options.limit) queryParams.append('limit', options.limit.toString());
if (options.name) queryParams.append('name', options.name);
const queryString = queryParams.toString();
const url = `/knowledge-groups${queryString ? `?${queryString}` : ''}`;
const response = await apiClient.request(url, {});
if (!response.ok) throw new Error('Failed to fetch groups');
const data = await response.json();
// If it's an array, it's already in the format we expect (tree or simple list)
if (Array.isArray(data)) return data;
// If it's a paginated object, return it as is or handle appropriately
// The callers of getGroups usually expect an array, but NotebooksView expects a tree.
// However, NotebookDetailView and others might soon expect pagination.
return data;
},
// Create group
async createGroup(data: CreateGroupData): Promise<KnowledgeGroup> {
const { data: group } = await apiClient.post<KnowledgeGroup>('/knowledge-groups', data);
return group;
},
// Update group
async updateGroup(id: string, data: UpdateGroupData): Promise<KnowledgeGroup> {
const { data: group } = await apiClient.put<KnowledgeGroup>(`/knowledge-groups/${id}`, data);
return group;
},
// Delete group
async deleteGroup(id: string): Promise<void> {
const response = await apiClient.request(`/knowledge-groups/${id}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete group');
},
// Fetch files in group
async getGroupFiles(id: string): Promise<any[]> {
const response = await apiClient.request(`/knowledge-groups/${id}/files`, {});
if (!response.ok) throw new Error('Failed to fetch group files');
const data = await response.json();
return data.files;
},
async addFileToGroups(fileId: string, groupIds: string[]): Promise<void> {
await apiClient.post(`/knowledge-bases/${fileId}/groups`, { groupIds });
},
// Remove file from group
async removeFileFromGroup(fileId: string, groupId: string): Promise<void> {
const response = await apiClient.request(`/knowledge-bases/${fileId}/groups/${groupId}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to remove file from group');
},
};
+82
View File
@@ -0,0 +1,82 @@
import { apiClient } from './apiClient';
import { ModelConfig } from '../types'; // Frontend ModelConfig interface
interface ModelConfigResponse extends Omit<ModelConfig, 'apiKey'> {
// Backend response might omit apiKey for security
id: string; // Ensure id is always present
createdAt: Date;
updatedAt: Date;
}
export const modelConfigService = {
async getAll(token: string): Promise<ModelConfigResponse[]> {
const response = await apiClient.request('/models', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to fetch model configs');
}
return response.json();
},
async create(token: string, modelConfig: Omit<ModelConfig, 'id'>): Promise<ModelConfigResponse> {
const response = await apiClient.request('/models', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(modelConfig),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to create model config');
}
return response.json();
},
async update(token: string, id: string, modelConfig: Partial<Omit<ModelConfig, 'id'>>): Promise<ModelConfigResponse> {
const response = await apiClient.request(`/models/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(modelConfig),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to update model config');
}
return response.json();
},
async remove(token: string, id: string): Promise<void> {
const response = await apiClient.request(`/models/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to delete model config');
}
},
async setDefault(token: string, id: string): Promise<ModelConfigResponse> {
const response = await apiClient.request(`/models/${id}/set-default`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to set default model');
}
return response.json();
},
};
+45
View File
@@ -0,0 +1,45 @@
import { API_BASE_URL, NoteCategory } from '../types';
export const noteCategoryService = {
async getAll(token: string): Promise<NoteCategory[]> {
const res = await fetch(`${API_BASE_URL}/v1/note-categories`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error('Failed to fetch categories');
return res.json();
},
async create(token: string, name: string, parentId?: string): Promise<NoteCategory> {
const response = await fetch(`${API_BASE_URL}/v1/note-categories`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ name, parentId })
})
if (!response.ok) throw new Error('Failed to create category')
return response.json()
},
async update(token: string, id: string, name?: string, parentId?: string): Promise<NoteCategory> {
const response = await fetch(`${API_BASE_URL}/v1/note-categories/${id}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ name, parentId })
})
if (!response.ok) throw new Error('Failed to update category')
return response.json()
},
async delete(token: string, id: string): Promise<void> {
const res = await fetch(`${API_BASE_URL}/v1/note-categories/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error('Failed to delete category');
}
};
+103
View File
@@ -0,0 +1,103 @@
import { API_BASE_URL, Note } from '../types'
export const noteService = {
// すべてのノートを取得(オプションでグループによるフィルタリングが可能)
getAll: async (token: string, groupId?: string, categoryId?: string): Promise<Note[]> => {
const url = new URL(`${API_BASE_URL}/notes`, window.location.origin)
if (groupId) {
url.searchParams.append('groupId', groupId)
}
if (categoryId) {
url.searchParams.append('categoryId', categoryId)
}
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`
}
})
if (!response.ok) {
throw new Error(`Failed to fetch notes: ${response.status} ${response.statusText}`)
}
return response.json()
},
// ノートを作成
create: async (token: string, data: { title: string, content: string, groupId: string, categoryId?: string }): Promise<Note> => {
const response = await fetch(`${API_BASE_URL}/notes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify(data)
})
if (!response.ok) {
throw new Error('Failed to create note')
}
return response.json()
},
// ノートを更新
update: async (token: string, id: string, data: { title?: string, content?: string, categoryId?: string }): Promise<Note> => {
const response = await fetch(`${API_BASE_URL}/notes/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify(data)
})
if (!response.ok) {
throw new Error('Failed to update note')
}
return response.json()
},
// Delete note
delete: async (token: string, id: string): Promise<void> => {
const response = await fetch(`${API_BASE_URL}/notes/${id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`
}
})
if (!response.ok) {
throw new Error('Failed to delete note')
}
},
// Index note to knowledge base (vectorize)
createFromPDFSelection: async (
token: string,
fileId: string,
screenshot: Blob,
groupId?: string,
categoryId?: string,
pageNumber?: number,
): Promise<Note> => {
const formData = new FormData()
formData.append('screenshot', screenshot, 'selection.png')
formData.append('fileId', fileId)
if (groupId) {
formData.append('groupId', groupId)
}
if (categoryId) {
formData.append('categoryId', categoryId)
}
if (pageNumber !== undefined) {
formData.append('pageNumber', pageNumber.toString())
}
const response = await fetch(`${API_BASE_URL}/notes/from-pdf-selection`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`
},
body: formData
})
if (!response.ok) {
throw new Error('Failed to create note from PDF selection')
}
return response.json()
}
}
+24
View File
@@ -0,0 +1,24 @@
import { apiClient } from './apiClient';
/**
* OCR Service - Handles image text recognition
*/
export const ocrService = {
// Recognize text in image
async recognizeText(authToken: string, image: Blob): Promise<string> {
const formData = new FormData();
formData.append('image', image);
const response = await apiClient.request('/ocr/recognize', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Failed to recognize text');
}
const data = await response.json();
return data.text;
}
};
+33
View File
@@ -0,0 +1,33 @@
import { PDFStatus } from '../types';
import { apiClient } from './apiClient';
export const pdfPreviewService = {
async getPDFUrl(fileId: string): Promise<{ url: string }> {
const { data } = await apiClient.get<{ url: string }>(`/knowledge-bases/${fileId}/pdf-url`);
return data;
},
async getPDFStatus(fileId: string): Promise<PDFStatus> {
const { data } = await apiClient.get<PDFStatus>(`/knowledge-bases/${fileId}/pdf-status`);
return data;
},
async preloadPDF(fileId: string, force: boolean = false): Promise<void> {
try {
const url = `/knowledge-bases/${fileId}/pdf-url` + (force ? '?force=true' : '');
const response = await apiClient.request(url, {
method: 'GET',
signal: AbortSignal.timeout(30000), // Increase timeout for conversion
});
if (response.ok) {
console.log('PDF already exists or conversion completed');
}
} catch (error: any) {
console.log('PDF conversion triggered:', error.message);
}
},
};
+64
View File
@@ -0,0 +1,64 @@
import * as pdfjs from 'pdfjs-dist';
// Set worker path - using a CDN for the worker to avoid complex vite configuration for now
// or we can try to use the bundled worker if vite handles it
pdfjs.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.mjs`;
export const pdfRenderService = {
async renderPageToCanvas(
pdfData: Blob | ArrayBuffer,
pageNumber: number,
canvas: HTMLCanvasElement,
targetWidth: number,
targetHeight: number
): Promise<void> {
const data = pdfData instanceof Blob ? await pdfData.arrayBuffer() : pdfData;
const loadingTask = pdfjs.getDocument({ data });
const pdf = await loadingTask.promise;
if (pageNumber < 1 || pageNumber > pdf.numPages) {
throw new Error(`Invalid page number: ${pageNumber}.`);
}
const page = await pdf.getPage(pageNumber);
// Calculate scale to fit container while maintaining aspect ratio
const unscaledViewport = page.getViewport({ scale: 1 });
// Calculate the scale needed to fit the PDF page within the target dimensions
const fitScale = Math.min(targetWidth / unscaledViewport.width, targetHeight / unscaledViewport.height);
// Calculate a higher scale for rendering quality (anti-aliasing and clarity)
const devicePixelRatio = window.devicePixelRatio || 1;
const renderScale = fitScale * Math.max(2, devicePixelRatio);
const viewport = page.getViewport({ scale: renderScale });
// Set canvas to the high-resolution dimensions to maintain quality
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext('2d');
if (!context) return;
// Save the context state before any transformations
context.save();
// Fill background
context.fillStyle = '#f8fafc';
context.fillRect(0, 0, canvas.width, canvas.height);
// Calculate the position to center the PDF page in the canvas
const offsetX = (canvas.width - viewport.width) / 2;
const offsetY = (canvas.height - viewport.height) / 2;
await page.render({
canvasContext: context,
viewport: viewport,
transform: [1, 0, 0, 1, offsetX, offsetY]
}).promise;
// Restore the context state after rendering
context.restore();
}
};
+41
View File
@@ -0,0 +1,41 @@
export interface RagSearchResult {
content: string;
fileName: string;
score: number;
chunkIndex: number;
}
export interface RagResponse {
searchResults: RagSearchResult[];
sources: string[];
ragPrompt: string;
hasRelevantContent: boolean;
}
export const ragService = {
async search(query: string, settings: any, authToken: string): Promise<RagResponse> {
try {
const response = await fetch('/api/knowledge-bases/rag-search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
body: JSON.stringify({ query, settings }),
});
if (!response.ok) {
const errorData = await response.text();
console.error('RAG search error:', response.status, errorData);
throw new Error(`RAG search failed: ${response.status} - ${errorData}`);
}
const result = await response.json();
return result;
} catch (error) {
console.error('RAG service error:', error);
throw error;
}
},
};
+32
View File
@@ -0,0 +1,32 @@
import { SearchHistoryItem, SearchHistoryDetail } from '../types';
import { apiClient } from './apiClient';
export const searchHistoryService = {
async getHistories(page: number = 1, limit: number = 20): Promise<{
histories: SearchHistoryItem[];
total: number;
page: number;
limit: number;
}> {
const { data } = await apiClient.get(`/search-history?page=${page}&limit=${limit}`);
return data;
},
async getHistoryDetail(id: string): Promise<SearchHistoryDetail> {
const { data } = await apiClient.get(`/search-history/${id}`);
return data;
},
async createHistory(title: string, selectedGroups?: string[]): Promise<{ id: string }> {
const { data } = await apiClient.post(`/search-history`, { title, selectedGroups });
return data;
},
async deleteHistory(id: string): Promise<void> {
await apiClient.delete(`/search-history/${id}`);
},
};
+33
View File
@@ -0,0 +1,33 @@
import { apiClient } from './apiClient';
export const settingsService = {
async getVisionModels() {
const response = await apiClient.get('/models');
// Filter models that support vision or are of type vision
return response.data.filter((m: any) => m.supportsVision || m.type === 'vision');
},
async getVisionModel() {
const response = await apiClient.get('/v1/admin/settings');
return { visionModelId: response.data.selectedVisionId };
},
async updateVisionModel(selectedVisionId: string) {
const response = await apiClient.put('/v1/admin/settings', {
selectedVisionId,
});
return response.data;
},
async getLanguage() {
const response = await apiClient.get('/users/settings');
return response.data.language;
},
async updateLanguage(language: string) {
const response = await apiClient.put('/users/settings/language', {
language,
});
return response.data;
},
};
+28
View File
@@ -0,0 +1,28 @@
import { apiClient } from './apiClient';
import { AssessmentTemplate, CreateTemplateData, UpdateTemplateData } from '../types';
export const templateService = {
async getAll(): Promise<AssessmentTemplate[]> {
const response = await apiClient.get<AssessmentTemplate[]>('/assessment/templates');
return response.data;
},
async getById(id: string): Promise<AssessmentTemplate> {
const response = await apiClient.get<AssessmentTemplate>(`/assessment/templates/${id}`);
return response.data;
},
async create(data: CreateTemplateData): Promise<AssessmentTemplate> {
const response = await apiClient.post<AssessmentTemplate>('/assessment/templates', data);
return response.data;
},
async update(id: string, data: UpdateTemplateData): Promise<AssessmentTemplate> {
const response = await apiClient.put<AssessmentTemplate>(`/assessment/templates/${id}`, data);
return response.data;
},
async delete(id: string): Promise<void> {
await apiClient.delete(`/assessment/templates/${id}`);
},
};
+132
View File
@@ -0,0 +1,132 @@
import { apiClient } from './apiClient';
import { IndexingConfig } from '../types';
// web/services/uploadService.ts
export const uploadService = {
async uploadFile(file: File, authToken: string): Promise<any> {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.request('/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'fileUploadFailed');
}
return response.json();
},
async uploadFileWithConfig(file: File, config: IndexingConfig, authToken: string): Promise<any> {
const formData = new FormData();
formData.append('file', file);
formData.append('chunkSize', config.chunkSize.toString());
formData.append('chunkOverlap', config.chunkOverlap.toString());
formData.append('embeddingModelId', config.embeddingModelId);
if (config.mode) {
formData.append('mode', config.mode);
}
// 分類を追加(指定されている場合)
if (config.groupIds && config.groupIds.length > 0) {
formData.append('groupIds', JSON.stringify(config.groupIds));
}
const response = await apiClient.request('/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'fileUploadFailed');
}
return response.json();
},
async uploadText(content: string, title: string, config: IndexingConfig, authToken: string): Promise<any> {
const { data } = await apiClient.post('/upload/text', {
content,
title,
chunkSize: config.chunkSize.toString(),
chunkOverlap: config.chunkOverlap.toString(),
embeddingModelId: config.embeddingModelId,
mode: config.mode
});
return data;
},
async recommendMode(file: File): Promise<any> {
if (!file || !file.name) {
return {
recommendedMode: 'fast',
reason: 'invalidFile',
warnings: ['incompleteFileInfo'],
};
}
const ext = file.name.toLowerCase().split('.').pop();
const sizeMB = file.size / (1024 * 1024);
const preciseFormats = ['pdf', 'doc', 'docx', 'ppt', 'pptx'];
const supportedFormats = [...preciseFormats, 'xls', 'xlsx', 'txt', 'md', 'html', 'json', 'csv'];
if (!supportedFormats.includes(ext || '')) {
return {
recommendedMode: 'fast',
reason: `unsupportedFileFormat`,
reasonArgs: [ext],
warnings: ['willUseFastMode'],
};
}
if (!preciseFormats.includes(ext || '')) {
return {
recommendedMode: 'fast',
reason: `formatNoPrecise`,
reasonArgs: [ext],
warnings: ['willUseFastMode'],
};
}
if (sizeMB < 5) {
return {
recommendedMode: 'fast',
reason: 'smallFileFastOk',
estimatedCost: 0,
estimatedTime: sizeMB * 2,
warnings: [],
};
}
if (sizeMB < 50) {
return {
recommendedMode: 'precise',
reason: 'mixedContentPreciseRecommended',
estimatedCost: Math.max(0.01, sizeMB * 0.01),
estimatedTime: sizeMB * 8,
warnings: ['willIncurApiCost'],
};
}
return {
recommendedMode: 'precise',
reason: 'largeFilePreciseRecommended',
estimatedCost: sizeMB * 0.015,
estimatedTime: sizeMB * 12,
warnings: ['longProcessingTime', 'highApiCost', 'considerFileSplitting'],
};
},
};
+58
View File
@@ -0,0 +1,58 @@
import { apiClient } from './apiClient';
export const userService = {
async changePassword(currentPassword: string, newPassword: string): Promise<{ message: string }> {
const { data } = await apiClient.put('/users/password', {
currentPassword,
newPassword,
});
return data;
},
async getUsers(page?: number, limit?: number): Promise<any> {
const params = new URLSearchParams();
if (page) params.append('page', page.toString());
if (limit) params.append('limit', limit.toString());
const { data } = await apiClient.get(`/users?${params.toString()}`);
return data;
},
async updateUser(userId: string, isAdmin: boolean): Promise<{ message: string }> {
const { data } = await apiClient.put(`/users/${userId}`, {
isAdmin,
});
return data;
},
async updateUserInfo(userId: string, userData: { username?: string; isAdmin?: boolean; password?: string; displayName?: string }): Promise<{ message: string }> {
const { data } = await apiClient.put(`/users/${userId}`, userData);
return data;
},
async deleteUser(userId: string): Promise<{ message: string }> {
const { data } = await apiClient.delete(`/users/${userId}`);
return data;
},
async createUser(username: string, password: string, isAdmin: boolean = false, tenantId?: string, displayName?: string): Promise<{ message: string }> {
const { data } = await apiClient.post('/users', {
username,
password,
isAdmin,
tenantId,
displayName,
});
return data;
},
async exportUsers(): Promise<Blob> {
return await apiClient.getBlob('/v1/admin/users/export');
},
async importUsers(file: File): Promise<any> {
const formData = new FormData();
formData.append('file', file);
const { data } = await apiClient.postMultipart('/v1/admin/users/import', formData);
return data;
},
};
+43
View File
@@ -0,0 +1,43 @@
// web/services/userSettingService.ts
import { apiClient } from './apiClient';
import { AppSettings } from '../types'; // Frontend AppSettings interface
// Assuming backend returns language, plus id, userId, createdAt, updatedAt
interface UserPersonalSettingResponse {
id: string;
userId: string;
language: string;
createdAt: Date;
updatedAt: Date;
}
interface UserSettingResponse extends AppSettings {
id: string;
userId: string;
createdAt: Date;
updatedAt: Date;
}
export const userSettingService = {
async get(_token: string): Promise<UserSettingResponse> {
const { data } = await apiClient.get<UserSettingResponse>('/v1/admin/settings');
return data;
},
async update(_token: string, settings: Partial<AppSettings>): Promise<UserSettingResponse> {
const { data } = await apiClient.put<UserSettingResponse>('/v1/admin/settings', settings);
return data;
},
async getPersonal(_token: string): Promise<UserPersonalSettingResponse> {
const { data } = await apiClient.get<UserPersonalSettingResponse>('/users/settings');
return data;
},
async updateLanguage(_token: string, language: string): Promise<UserPersonalSettingResponse> {
const { data } = await apiClient.put<UserPersonalSettingResponse>('/users/settings/language', { language });
return data;
},
// Unused legacy methods removed
};