forked from hangshuo652/aurak
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:
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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}`;
|
||||
},
|
||||
};
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
@@ -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'],
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user