ui ux enhancement
This commit is contained in:
parent
6dd2d2d6c1
commit
340042682a
@ -25,6 +25,6 @@ export { MCPExtension } from './mcp'
|
|||||||
export * from './engines'
|
export * from './engines'
|
||||||
|
|
||||||
export { RAGExtension, RAG_INTERNAL_SERVER } from './rag'
|
export { RAGExtension, RAG_INTERNAL_SERVER } from './rag'
|
||||||
export type { AttachmentInput } from './rag'
|
export type { AttachmentInput, IngestAttachmentsResult } from './rag'
|
||||||
export { VectorDBExtension } from './vector-db'
|
export { VectorDBExtension } from './vector-db'
|
||||||
export type { SearchMode, VectorDBStatus, VectorChunkInput, VectorSearchResult, AttachmentFileInfo } from './vector-db'
|
export type { SearchMode, VectorDBStatus, VectorChunkInput, VectorSearchResult, AttachmentFileInfo, VectorDBFileInput, VectorDBIngestOptions } from './vector-db'
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { BaseExtension, ExtensionTypeEnum } from '../extension'
|
import { BaseExtension, ExtensionTypeEnum } from '../extension'
|
||||||
import type { MCPTool, MCPToolCallResult } from '../../types'
|
import type { MCPTool, MCPToolCallResult } from '../../types'
|
||||||
|
import type { AttachmentFileInfo } from './vector-db'
|
||||||
|
|
||||||
export interface AttachmentInput {
|
export interface AttachmentInput {
|
||||||
path: string
|
path: string
|
||||||
@ -8,6 +9,12 @@ export interface AttachmentInput {
|
|||||||
size?: number
|
size?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IngestAttachmentsResult {
|
||||||
|
filesProcessed: number
|
||||||
|
chunksInserted: number
|
||||||
|
files: AttachmentFileInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
export const RAG_INTERNAL_SERVER = 'rag-internal'
|
export const RAG_INTERNAL_SERVER = 'rag-internal'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -25,8 +32,5 @@ export abstract class RAGExtension extends BaseExtension {
|
|||||||
abstract getToolNames(): Promise<string[]>
|
abstract getToolNames(): Promise<string[]>
|
||||||
abstract callTool(toolName: string, args: Record<string, unknown>): Promise<MCPToolCallResult>
|
abstract callTool(toolName: string, args: Record<string, unknown>): Promise<MCPToolCallResult>
|
||||||
|
|
||||||
abstract ingestAttachments(threadId: string, files: AttachmentInput[]): Promise<{
|
abstract ingestAttachments(threadId: string, files: AttachmentInput[]): Promise<IngestAttachmentsResult>
|
||||||
filesProcessed: number
|
|
||||||
chunksInserted: number
|
|
||||||
} | void>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,10 +7,8 @@ export interface VectorDBStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface VectorChunkInput {
|
export interface VectorChunkInput {
|
||||||
id?: string
|
|
||||||
text: string
|
text: string
|
||||||
embedding: number[]
|
embedding: number[]
|
||||||
metadata?: Record<string, any>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VectorSearchResult {
|
export interface VectorSearchResult {
|
||||||
@ -30,6 +28,19 @@ export interface AttachmentFileInfo {
|
|||||||
chunk_count: number
|
chunk_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// High-level input types for file ingestion
|
||||||
|
export interface VectorDBFileInput {
|
||||||
|
path: string
|
||||||
|
name?: string
|
||||||
|
type?: string
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VectorDBIngestOptions {
|
||||||
|
chunkSize: number
|
||||||
|
chunkOverlap: number
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vector DB extension base: abstraction over local vector storage and search.
|
* Vector DB extension base: abstraction over local vector storage and search.
|
||||||
*/
|
*/
|
||||||
@ -39,22 +50,31 @@ export abstract class VectorDBExtension extends BaseExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
abstract getStatus(): Promise<VectorDBStatus>
|
abstract getStatus(): Promise<VectorDBStatus>
|
||||||
abstract createCollection(name: string, dimension: number): Promise<void>
|
abstract createCollection(threadId: string, dimension: number): Promise<void>
|
||||||
abstract insertChunks(collection: string, chunks: VectorChunkInput[]): Promise<void>
|
abstract insertChunks(
|
||||||
|
threadId: string,
|
||||||
|
fileId: string,
|
||||||
|
chunks: VectorChunkInput[]
|
||||||
|
): Promise<void>
|
||||||
|
abstract ingestFile(
|
||||||
|
threadId: string,
|
||||||
|
file: VectorDBFileInput,
|
||||||
|
opts: VectorDBIngestOptions
|
||||||
|
): Promise<AttachmentFileInfo>
|
||||||
abstract searchCollection(
|
abstract searchCollection(
|
||||||
collection: string,
|
threadId: string,
|
||||||
query_embedding: number[],
|
query_embedding: number[],
|
||||||
limit: number,
|
limit: number,
|
||||||
threshold: number,
|
threshold: number,
|
||||||
mode?: SearchMode,
|
mode?: SearchMode,
|
||||||
fileIds?: string[]
|
fileIds?: string[]
|
||||||
): Promise<VectorSearchResult[]>
|
): Promise<VectorSearchResult[]>
|
||||||
abstract deleteChunks(collection: string, ids: string[]): Promise<void>
|
abstract deleteChunks(threadId: string, ids: string[]): Promise<void>
|
||||||
abstract deleteCollection(collection: string): Promise<void>
|
abstract deleteFile(threadId: string, fileId: string): Promise<void>
|
||||||
abstract chunkText(text: string, chunkSize: number, chunkOverlap: number): Promise<string[]>
|
abstract deleteCollection(threadId: string): Promise<void>
|
||||||
abstract listAttachments(collection: string, limit?: number): Promise<AttachmentFileInfo[]>
|
abstract listAttachments(threadId: string, limit?: number): Promise<AttachmentFileInfo[]>
|
||||||
abstract getChunks(
|
abstract getChunks(
|
||||||
collection: string,
|
threadId: string,
|
||||||
fileId: string,
|
fileId: string,
|
||||||
startOrder: number,
|
startOrder: number,
|
||||||
endOrder: number
|
endOrder: number
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import { RAGExtension, MCPTool, MCPToolCallResult, ExtensionTypeEnum, VectorDBExtension, type AttachmentInput, type SettingComponentProps, AIEngine } from '@janhq/core'
|
import { RAGExtension, MCPTool, MCPToolCallResult, ExtensionTypeEnum, VectorDBExtension, type AttachmentInput, type SettingComponentProps, AIEngine, type AttachmentFileInfo } from '@janhq/core'
|
||||||
import './env.d'
|
import './env.d'
|
||||||
import * as ragApi from '@janhq/tauri-plugin-rag-api'
|
|
||||||
import * as vecdbApi from '@janhq/tauri-plugin-vector-db-api'
|
|
||||||
import { getRAGTools, RETRIEVE, LIST_ATTACHMENTS, GET_CHUNKS } from './tools'
|
import { getRAGTools, RETRIEVE, LIST_ATTACHMENTS, GET_CHUNKS } from './tools'
|
||||||
|
|
||||||
export default class RagExtension extends RAGExtension {
|
export default class RagExtension extends RAGExtension {
|
||||||
@ -75,11 +73,10 @@ export default class RagExtension extends RAGExtension {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const vec = window.core?.extensionManager.get(ExtensionTypeEnum.VectorDB) as unknown as VectorDBExtension
|
const vec = window.core?.extensionManager.get(ExtensionTypeEnum.VectorDB) as unknown as VectorDBExtension
|
||||||
const collection = `attachments_${threadId}`
|
|
||||||
if (!vec?.listAttachments) {
|
if (!vec?.listAttachments) {
|
||||||
return { error: 'Vector DB extension missing listAttachments', content: [{ type: 'text', text: 'Vector DB extension missing listAttachments' }] }
|
return { error: 'Vector DB extension missing listAttachments', content: [{ type: 'text', text: 'Vector DB extension missing listAttachments' }] }
|
||||||
}
|
}
|
||||||
const files = await vec.listAttachments(collection)
|
const files = await vec.listAttachments(threadId)
|
||||||
return {
|
return {
|
||||||
error: '',
|
error: '',
|
||||||
content: [
|
content: [
|
||||||
@ -143,9 +140,8 @@ export default class RagExtension extends RAGExtension {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const collection = `attachments_${threadId}`
|
|
||||||
const results = await vec.searchCollection(
|
const results = await vec.searchCollection(
|
||||||
collection,
|
threadId,
|
||||||
queryEmb,
|
queryEmb,
|
||||||
topK,
|
topK,
|
||||||
threshold,
|
threshold,
|
||||||
@ -163,7 +159,6 @@ export default class RagExtension extends RAGExtension {
|
|||||||
file_id: r.file_id,
|
file_id: r.file_id,
|
||||||
chunk_file_order: r.chunk_file_order
|
chunk_file_order: r.chunk_file_order
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
collection,
|
|
||||||
mode,
|
mode,
|
||||||
}
|
}
|
||||||
return { error: '', content: [{ type: 'text', text: JSON.stringify(payload) }] }
|
return { error: '', content: [{ type: 'text', text: JSON.stringify(payload) }] }
|
||||||
@ -203,8 +198,7 @@ export default class RagExtension extends RAGExtension {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const collection = `attachments_${threadId}`
|
const chunks = await vec.getChunks(threadId, fileId, startOrder, endOrder)
|
||||||
const chunks = await vec.getChunks(collection, fileId, startOrder, endOrder)
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
thread_id: threadId,
|
thread_id: threadId,
|
||||||
@ -222,8 +216,15 @@ export default class RagExtension extends RAGExtension {
|
|||||||
async ingestAttachments(
|
async ingestAttachments(
|
||||||
threadId: string,
|
threadId: string,
|
||||||
files: AttachmentInput[]
|
files: AttachmentInput[]
|
||||||
): Promise<{ filesProcessed: number; chunksInserted: number } | void> {
|
): Promise<{ filesProcessed: number; chunksInserted: number; files: AttachmentFileInfo[] }> {
|
||||||
if (!threadId || !Array.isArray(files) || files.length === 0) return
|
if (!threadId || !Array.isArray(files) || files.length === 0) {
|
||||||
|
return { filesProcessed: 0, chunksInserted: 0, files: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respect feature flag: do nothing when disabled
|
||||||
|
if (this.config.enabled === false) {
|
||||||
|
return { filesProcessed: 0, chunksInserted: 0, files: [] }
|
||||||
|
}
|
||||||
|
|
||||||
const vec = window.core?.extensionManager.get(ExtensionTypeEnum.VectorDB) as unknown as VectorDBExtension
|
const vec = window.core?.extensionManager.get(ExtensionTypeEnum.VectorDB) as unknown as VectorDBExtension
|
||||||
if (!vec?.createCollection || !vec?.insertChunks) {
|
if (!vec?.createCollection || !vec?.insertChunks) {
|
||||||
@ -237,41 +238,32 @@ export default class RagExtension extends RAGExtension {
|
|||||||
const chunkOverlap = s?.overlapTokens as number | undefined
|
const chunkOverlap = s?.overlapTokens as number | undefined
|
||||||
|
|
||||||
let totalChunks = 0
|
let totalChunks = 0
|
||||||
let processed = 0
|
const processedFiles: AttachmentFileInfo[] = []
|
||||||
const collection = `attachments_${threadId}`
|
|
||||||
let created = false
|
|
||||||
|
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
if (!f?.path) continue
|
if (!f?.path) continue
|
||||||
if (maxSize && f.size && f.size > maxSize * 1024 * 1024) continue
|
if (maxSize && f.size && f.size > maxSize * 1024 * 1024) {
|
||||||
|
throw new Error(`File '${f.name}' exceeds size limit (${f.size} bytes > ${maxSize} MB).`)
|
||||||
|
}
|
||||||
|
|
||||||
const type = f.type || 'application/octet-stream'
|
const fileName = f.name || f.path.split(/[\\/]/).pop()
|
||||||
const chunks = await this.parseAndEmbed(f.path, type, {
|
// Preferred/required path: let Vector DB extension handle full file ingestion
|
||||||
chunkSize: chunkSize ?? 512,
|
const canIngestFile = typeof (vec as any)?.ingestFile === 'function'
|
||||||
chunkOverlap: chunkOverlap ?? 64,
|
if (!canIngestFile) {
|
||||||
meta: { file: { name: f.name || f.path.split(/[\\/]/).pop(), path: f.path, type: f.type, size: f.size }, threadId },
|
console.error('[RAG] Vector DB extension missing ingestFile; cannot ingest document')
|
||||||
})
|
|
||||||
|
|
||||||
if (!chunks?.length) {
|
|
||||||
processed += 1
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
const info = await (vec as VectorDBExtension).ingestFile(
|
||||||
// Ensure collection
|
threadId,
|
||||||
if (!created) {
|
{ path: f.path, name: fileName, type: f.type, size: f.size },
|
||||||
await vec.createCollection(collection, chunks[0].embedding.length)
|
{ chunkSize: chunkSize ?? 512, chunkOverlap: chunkOverlap ?? 64 }
|
||||||
created = true
|
|
||||||
}
|
|
||||||
|
|
||||||
await vec.insertChunks(
|
|
||||||
collection,
|
|
||||||
chunks.map((c) => ({ text: c.text, embedding: c.embedding, metadata: c.metadata }))
|
|
||||||
)
|
)
|
||||||
totalChunks += chunks.length
|
totalChunks += Number(info?.chunk_count || 0)
|
||||||
processed += 1
|
processedFiles.push(info)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { filesProcessed: processed, chunksInserted: totalChunks }
|
// Return files we ingested with real IDs directly from ingestFile
|
||||||
|
return { filesProcessed: processedFiles.length, chunksInserted: totalChunks, files: processedFiles }
|
||||||
}
|
}
|
||||||
|
|
||||||
onSettingUpdate<T>(key: string, value: T): void {
|
onSettingUpdate<T>(key: string, value: T): void {
|
||||||
@ -310,19 +302,4 @@ export default class RagExtension extends RAGExtension {
|
|||||||
for (const item of data) out[item.index] = item.embedding
|
for (const item of data) out[item.index] = item.embedding
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
private async parseAndEmbed(
|
|
||||||
filePath: string,
|
|
||||||
fileType: string,
|
|
||||||
opts: { chunkSize: number; chunkOverlap: number; meta?: Record<string, any> }
|
|
||||||
): Promise<Array<{ text: string; embedding: number[]; metadata?: Record<string, any> }>> {
|
|
||||||
const text = await ragApi.parseDocument(filePath, fileType)
|
|
||||||
const chunks = await vecdbApi.chunkText(text, opts.chunkSize, opts.chunkOverlap)
|
|
||||||
const embeddings = await this.embedTexts(chunks)
|
|
||||||
return chunks.map((text, i) => ({
|
|
||||||
text,
|
|
||||||
embedding: embeddings[i],
|
|
||||||
metadata: opts.meta,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@janhq/core": "../../core/package.tgz",
|
"@janhq/core": "../../core/package.tgz",
|
||||||
|
"@janhq/tauri-plugin-rag-api": "link:../../src-tauri/plugins/tauri-plugin-rag",
|
||||||
"@janhq/tauri-plugin-vector-db-api": "link:../../src-tauri/plugins/tauri-plugin-vector-db"
|
"@janhq/tauri-plugin-vector-db-api": "link:../../src-tauri/plugins/tauri-plugin-vector-db"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { VectorDBExtension, type SearchMode, type VectorDBStatus, type VectorChunkInput, type VectorSearchResult, type AttachmentFileInfo } from '@janhq/core'
|
import { VectorDBExtension, type SearchMode, type VectorDBStatus, type VectorChunkInput, type VectorSearchResult, type AttachmentFileInfo, type VectorDBFileInput, type VectorDBIngestOptions, AIEngine } from '@janhq/core'
|
||||||
import * as vecdb from '@janhq/tauri-plugin-vector-db-api'
|
import * as vecdb from '@janhq/tauri-plugin-vector-db-api'
|
||||||
|
import * as ragApi from '@janhq/tauri-plugin-rag-api'
|
||||||
|
|
||||||
export default class VectorDBExt extends VectorDBExtension {
|
export default class VectorDBExt extends VectorDBExtension {
|
||||||
async onLoad(): Promise<void> {
|
async onLoad(): Promise<void> {
|
||||||
@ -12,47 +13,95 @@ export default class VectorDBExt extends VectorDBExtension {
|
|||||||
return await vecdb.getStatus() as VectorDBStatus
|
return await vecdb.getStatus() as VectorDBStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCollection(name: string, dimension: number): Promise<void> {
|
private collectionForThread(threadId: string): string {
|
||||||
return await vecdb.createCollection(name, dimension)
|
return `attachments_${threadId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async insertChunks(collection: string, chunks: VectorChunkInput[]): Promise<void> {
|
async createCollection(threadId: string, dimension: number): Promise<void> {
|
||||||
return await vecdb.insertChunks(collection, chunks)
|
return await vecdb.createCollection(this.collectionForThread(threadId), dimension)
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertChunks(threadId: string, fileId: string, chunks: VectorChunkInput[]): Promise<void> {
|
||||||
|
return await vecdb.insertChunks(this.collectionForThread(threadId), fileId, chunks)
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchCollection(
|
async searchCollection(
|
||||||
collection: string,
|
threadId: string,
|
||||||
query_embedding: number[],
|
query_embedding: number[],
|
||||||
limit: number,
|
limit: number,
|
||||||
threshold: number,
|
threshold: number,
|
||||||
mode?: SearchMode,
|
mode?: SearchMode,
|
||||||
fileIds?: string[]
|
fileIds?: string[]
|
||||||
): Promise<VectorSearchResult[]> {
|
): Promise<VectorSearchResult[]> {
|
||||||
return await vecdb.searchCollection(collection, query_embedding, limit, threshold, mode, fileIds) as VectorSearchResult[]
|
return await vecdb.searchCollection(this.collectionForThread(threadId), query_embedding, limit, threshold, mode, fileIds) as VectorSearchResult[]
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteChunks(collection: string, ids: string[]): Promise<void> {
|
async deleteChunks(threadId: string, ids: string[]): Promise<void> {
|
||||||
return await vecdb.deleteChunks(collection, ids)
|
return await vecdb.deleteChunks(this.collectionForThread(threadId), ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteCollection(collection: string): Promise<void> {
|
async deleteCollection(threadId: string): Promise<void> {
|
||||||
return await vecdb.deleteCollection(collection)
|
return await vecdb.deleteCollection(this.collectionForThread(threadId))
|
||||||
}
|
}
|
||||||
|
|
||||||
async chunkText(text: string, chunkSize: number, chunkOverlap: number): Promise<string[]> {
|
// Optional helper for chunking
|
||||||
|
private async chunkText(text: string, chunkSize: number, chunkOverlap: number): Promise<string[]> {
|
||||||
return await vecdb.chunkText(text, chunkSize, chunkOverlap)
|
return await vecdb.chunkText(text, chunkSize, chunkOverlap)
|
||||||
}
|
}
|
||||||
|
|
||||||
async listAttachments(collection: string, limit?: number): Promise<AttachmentFileInfo[]> {
|
private async embedTexts(texts: string[]): Promise<number[][]> {
|
||||||
return await vecdb.listAttachments(collection, limit) as AttachmentFileInfo[]
|
const llm = window.core?.extensionManager.getByName('@janhq/llamacpp-extension') as AIEngine & { embed?: (texts: string[]) => Promise<{ data: Array<{ embedding: number[]; index: number }> }> }
|
||||||
|
if (!llm?.embed) throw new Error('llamacpp extension not available')
|
||||||
|
const res = await llm.embed(texts)
|
||||||
|
const data: Array<{ embedding: number[]; index: number }> = res?.data || []
|
||||||
|
const out: number[][] = new Array(texts.length)
|
||||||
|
for (const item of data) out[item.index] = item.embedding
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
async ingestFile(threadId: string, file: VectorDBFileInput, opts: VectorDBIngestOptions): Promise<AttachmentFileInfo> {
|
||||||
|
// Check for duplicate file (same name + path)
|
||||||
|
const existingFiles = await vecdb.listAttachments(this.collectionForThread(threadId)).catch(() => [])
|
||||||
|
const duplicate = existingFiles.find((f: any) => f.name === file.name && f.path === file.path)
|
||||||
|
if (duplicate) {
|
||||||
|
throw new Error(`File '${file.name}' has already been attached to this thread`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await ragApi.parseDocument(file.path, file.type || 'application/octet-stream')
|
||||||
|
const chunks = await this.chunkText(text, opts.chunkSize, opts.chunkOverlap)
|
||||||
|
if (!chunks.length) {
|
||||||
|
const fi = await vecdb.createFile(this.collectionForThread(threadId), file)
|
||||||
|
return fi
|
||||||
|
}
|
||||||
|
const embeddings = await this.embedTexts(chunks)
|
||||||
|
const dimension = embeddings[0]?.length || 0
|
||||||
|
if (dimension <= 0) throw new Error('Embedding dimension not available')
|
||||||
|
await this.createCollection(threadId, dimension)
|
||||||
|
const fi = await vecdb.createFile(this.collectionForThread(threadId), file)
|
||||||
|
await vecdb.insertChunks(
|
||||||
|
this.collectionForThread(threadId),
|
||||||
|
fi.id,
|
||||||
|
chunks.map((t, i) => ({ text: t, embedding: embeddings[i] }))
|
||||||
|
)
|
||||||
|
const infos = await vecdb.listAttachments(this.collectionForThread(threadId))
|
||||||
|
const updated = infos.find((e) => e.id === fi.id)
|
||||||
|
return updated || { ...fi, chunk_count: chunks.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAttachments(threadId: string, limit?: number): Promise<AttachmentFileInfo[]> {
|
||||||
|
return await vecdb.listAttachments(this.collectionForThread(threadId), limit) as AttachmentFileInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChunks(
|
async getChunks(
|
||||||
collection: string,
|
threadId: string,
|
||||||
fileId: string,
|
fileId: string,
|
||||||
startOrder: number,
|
startOrder: number,
|
||||||
endOrder: number
|
endOrder: number
|
||||||
): Promise<VectorSearchResult[]> {
|
): Promise<VectorSearchResult[]> {
|
||||||
return await vecdb.getChunks(collection, fileId, startOrder, endOrder) as VectorSearchResult[]
|
return await vecdb.getChunks(this.collectionForThread(threadId), fileId, startOrder, endOrder) as VectorSearchResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFile(threadId: string, fileId: string): Promise<void> {
|
||||||
|
return await vecdb.deleteFile(this.collectionForThread(threadId), fileId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1513
src-tauri/Cargo.lock
generated
1513
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -63,7 +63,9 @@ log = "0.4"
|
|||||||
rmcp = { version = "0.6.0", features = [
|
rmcp = { version = "0.6.0", features = [
|
||||||
"client",
|
"client",
|
||||||
"transport-sse-client",
|
"transport-sse-client",
|
||||||
|
"transport-sse-client-reqwest",
|
||||||
"transport-streamable-http-client",
|
"transport-streamable-http-client",
|
||||||
|
"transport-streamable-http-client-reqwest",
|
||||||
"transport-child-process",
|
"transport-child-process",
|
||||||
"tower",
|
"tower",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|||||||
@ -13,8 +13,24 @@ use std::borrow::Cow;
|
|||||||
|
|
||||||
pub fn parse_pdf(file_path: &str) -> Result<String, RagError> {
|
pub fn parse_pdf(file_path: &str) -> Result<String, RagError> {
|
||||||
let bytes = fs::read(file_path)?;
|
let bytes = fs::read(file_path)?;
|
||||||
pdf_extract::extract_text_from_mem(&bytes)
|
let text = pdf_extract::extract_text_from_mem(&bytes)
|
||||||
.map_err(|e| RagError::ParseError(format!("PDF parse error: {}", e)))
|
.map_err(|e| RagError::ParseError(format!("PDF parse error: {}", e)))?;
|
||||||
|
|
||||||
|
// Validate that the PDF has extractable text (not image-based/scanned)
|
||||||
|
// Count meaningful characters (excluding whitespace)
|
||||||
|
let meaningful_chars = text.chars()
|
||||||
|
.filter(|c| !c.is_whitespace())
|
||||||
|
.count();
|
||||||
|
|
||||||
|
// Require at least 50 non-whitespace characters to consider it a text PDF
|
||||||
|
// This threshold filters out PDFs that are purely images or scanned documents
|
||||||
|
if meaningful_chars < 50 {
|
||||||
|
return Err(RagError::ParseError(
|
||||||
|
"PDF appears to be image-based or scanned. OCR is not supported yet. Please use a text-based PDF.".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_text(file_path: &str) -> Result<String, RagError> {
|
pub fn parse_text(file_path: &str) -> Result<String, RagError> {
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
tauri_plugin::Builder::new(&[
|
tauri_plugin::Builder::new(&[
|
||||||
"create_collection",
|
"create_collection",
|
||||||
|
"create_file",
|
||||||
"insert_chunks",
|
"insert_chunks",
|
||||||
"search_collection",
|
"search_collection",
|
||||||
"delete_chunks",
|
"delete_chunks",
|
||||||
|
"delete_file",
|
||||||
"delete_collection",
|
"delete_collection",
|
||||||
"chunk_text",
|
"chunk_text",
|
||||||
"get_status",
|
"get_status",
|
||||||
|
"list_attachments",
|
||||||
|
"get_chunks",
|
||||||
])
|
])
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,13 +2,6 @@ import { invoke } from '@tauri-apps/api/core'
|
|||||||
|
|
||||||
export type SearchMode = 'auto' | 'ann' | 'linear'
|
export type SearchMode = 'auto' | 'ann' | 'linear'
|
||||||
|
|
||||||
export interface ChunkInput {
|
|
||||||
id?: string
|
|
||||||
text: string
|
|
||||||
embedding: number[]
|
|
||||||
metadata?: Record<string, any>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchResult {
|
export interface SearchResult {
|
||||||
id: string
|
id: string
|
||||||
text: string
|
text: string
|
||||||
@ -42,11 +35,26 @@ export async function createCollection(name: string, dimension: number): Promise
|
|||||||
return await invoke('plugin:vector-db|create_collection', { name, dimension })
|
return await invoke('plugin:vector-db|create_collection', { name, dimension })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createFile(
|
||||||
|
collection: string,
|
||||||
|
file: { path: string; name?: string; type?: string; size?: number }
|
||||||
|
): Promise<AttachmentFileInfo> {
|
||||||
|
return await invoke('plugin:vector-db|create_file', { collection, file })
|
||||||
|
}
|
||||||
|
|
||||||
export async function insertChunks(
|
export async function insertChunks(
|
||||||
collection: string,
|
collection: string,
|
||||||
chunks: ChunkInput[]
|
fileId: string,
|
||||||
|
chunks: Array<{ text: string; embedding: number[] }>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return await invoke('plugin:vector-db|insert_chunks', { collection, chunks })
|
return await invoke('plugin:vector-db|insert_chunks', { collection, fileId, chunks })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFile(
|
||||||
|
collection: string,
|
||||||
|
fileId: string
|
||||||
|
): Promise<void> {
|
||||||
|
return await invoke('plugin:vector-db|delete_file', { collection, fileId })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchCollection(
|
export async function searchCollection(
|
||||||
|
|||||||
@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-create-file"
|
||||||
|
description = "Enables the create_file command without any pre-configured scope."
|
||||||
|
commands.allow = ["create_file"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-create-file"
|
||||||
|
description = "Denies the create_file command without any pre-configured scope."
|
||||||
|
commands.deny = ["create_file"]
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-delete-file"
|
||||||
|
description = "Enables the delete_file command without any pre-configured scope."
|
||||||
|
commands.allow = ["delete_file"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-delete-file"
|
||||||
|
description = "Denies the delete_file command without any pre-configured scope."
|
||||||
|
commands.deny = ["delete_file"]
|
||||||
@ -11,4 +11,3 @@ commands.allow = ["list_attachments"]
|
|||||||
identifier = "deny-list-attachments"
|
identifier = "deny-list-attachments"
|
||||||
description = "Denies the list_attachments command without any pre-configured scope."
|
description = "Denies the list_attachments command without any pre-configured scope."
|
||||||
commands.deny = ["list_attachments"]
|
commands.deny = ["list_attachments"]
|
||||||
|
|
||||||
|
|||||||
@ -7,8 +7,10 @@ Default permissions for the vector-db plugin
|
|||||||
- `allow-get-status`
|
- `allow-get-status`
|
||||||
- `allow-create-collection`
|
- `allow-create-collection`
|
||||||
- `allow-insert-chunks`
|
- `allow-insert-chunks`
|
||||||
|
- `allow-create-file`
|
||||||
- `allow-search-collection`
|
- `allow-search-collection`
|
||||||
- `allow-delete-chunks`
|
- `allow-delete-chunks`
|
||||||
|
- `allow-delete-file`
|
||||||
- `allow-delete-collection`
|
- `allow-delete-collection`
|
||||||
- `allow-chunk-text`
|
- `allow-chunk-text`
|
||||||
- `allow-list-attachments`
|
- `allow-list-attachments`
|
||||||
@ -78,6 +80,32 @@ Denies the create_collection command without any pre-configured scope.
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
|
|
||||||
|
`vector-db:allow-create-file`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the create_file command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`vector-db:deny-create-file`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the create_file command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
`vector-db:allow-delete-chunks`
|
`vector-db:allow-delete-chunks`
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
@ -130,6 +158,32 @@ Denies the delete_collection command without any pre-configured scope.
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
|
|
||||||
|
`vector-db:allow-delete-file`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the delete_file command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`vector-db:deny-delete-file`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the delete_file command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
`vector-db:allow-get-chunks`
|
`vector-db:allow-get-chunks`
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -4,8 +4,10 @@ permissions = [
|
|||||||
"allow-get-status",
|
"allow-get-status",
|
||||||
"allow-create-collection",
|
"allow-create-collection",
|
||||||
"allow-insert-chunks",
|
"allow-insert-chunks",
|
||||||
|
"allow-create-file",
|
||||||
"allow-search-collection",
|
"allow-search-collection",
|
||||||
"allow-delete-chunks",
|
"allow-delete-chunks",
|
||||||
|
"allow-delete-file",
|
||||||
"allow-delete-collection",
|
"allow-delete-collection",
|
||||||
"allow-chunk-text",
|
"allow-chunk-text",
|
||||||
"allow-list-attachments",
|
"allow-list-attachments",
|
||||||
|
|||||||
@ -318,6 +318,18 @@
|
|||||||
"const": "deny-create-collection",
|
"const": "deny-create-collection",
|
||||||
"markdownDescription": "Denies the create_collection command without any pre-configured scope."
|
"markdownDescription": "Denies the create_collection command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the create_file command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-create-file",
|
||||||
|
"markdownDescription": "Enables the create_file command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the create_file command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-create-file",
|
||||||
|
"markdownDescription": "Denies the create_file command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the delete_chunks command without any pre-configured scope.",
|
"description": "Enables the delete_chunks command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@ -342,6 +354,18 @@
|
|||||||
"const": "deny-delete-collection",
|
"const": "deny-delete-collection",
|
||||||
"markdownDescription": "Denies the delete_collection command without any pre-configured scope."
|
"markdownDescription": "Denies the delete_collection command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the delete_file command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-delete-file",
|
||||||
|
"markdownDescription": "Enables the delete_file command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the delete_file command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-delete-file",
|
||||||
|
"markdownDescription": "Denies the delete_file command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the get_chunks command without any pre-configured scope.",
|
"description": "Enables the get_chunks command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@ -403,10 +427,10 @@
|
|||||||
"markdownDescription": "Denies the search_collection command without any pre-configured scope."
|
"markdownDescription": "Denies the search_collection command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Default permissions for the vector-db plugin\n#### This default permission set includes:\n\n- `allow-get-status`\n- `allow-create-collection`\n- `allow-insert-chunks`\n- `allow-search-collection`\n- `allow-delete-chunks`\n- `allow-delete-collection`\n- `allow-chunk-text`\n- `allow-list-attachments`\n- `allow-get-chunks`",
|
"description": "Default permissions for the vector-db plugin\n#### This default permission set includes:\n\n- `allow-get-status`\n- `allow-create-collection`\n- `allow-insert-chunks`\n- `allow-create-file`\n- `allow-search-collection`\n- `allow-delete-chunks`\n- `allow-delete-file`\n- `allow-delete-collection`\n- `allow-chunk-text`\n- `allow-list-attachments`\n- `allow-get-chunks`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "default",
|
"const": "default",
|
||||||
"markdownDescription": "Default permissions for the vector-db plugin\n#### This default permission set includes:\n\n- `allow-get-status`\n- `allow-create-collection`\n- `allow-insert-chunks`\n- `allow-search-collection`\n- `allow-delete-chunks`\n- `allow-delete-collection`\n- `allow-chunk-text`\n- `allow-list-attachments`\n- `allow-get-chunks`"
|
"markdownDescription": "Default permissions for the vector-db plugin\n#### This default permission set includes:\n\n- `allow-get-status`\n- `allow-create-collection`\n- `allow-insert-chunks`\n- `allow-create-file`\n- `allow-search-collection`\n- `allow-delete-chunks`\n- `allow-delete-file`\n- `allow-delete-collection`\n- `allow-chunk-text`\n- `allow-list-attachments`\n- `allow-get-chunks`"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
use crate::{VectorDBError, VectorDBState};
|
use crate::{VectorDBError, VectorDBState};
|
||||||
use crate::db::{
|
use crate::db::{
|
||||||
self, AttachmentFileInfo, ChunkInput, SearchResult,
|
self, AttachmentFileInfo, SearchResult, MinimalChunkInput,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
@ -10,6 +10,15 @@ pub struct Status {
|
|||||||
pub ann_available: bool,
|
pub ann_available: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct FileInput {
|
||||||
|
pub path: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub file_type: Option<String>,
|
||||||
|
pub size: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Tauri Command Handlers
|
// Tauri Command Handlers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -74,18 +83,48 @@ pub async fn create_collection<R: tauri::Runtime>(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_file<R: tauri::Runtime>(
|
||||||
|
_app: tauri::AppHandle<R>,
|
||||||
|
state: State<'_, VectorDBState>,
|
||||||
|
collection: String,
|
||||||
|
file: FileInput,
|
||||||
|
) -> Result<AttachmentFileInfo, VectorDBError> {
|
||||||
|
let path = db::collection_path(&state.base_dir, &collection);
|
||||||
|
let conn = db::open_or_init_conn(&path)?;
|
||||||
|
db::create_file(
|
||||||
|
&conn,
|
||||||
|
&file.path,
|
||||||
|
file.name.as_deref(),
|
||||||
|
file.file_type.as_deref(),
|
||||||
|
file.size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn insert_chunks<R: tauri::Runtime>(
|
pub async fn insert_chunks<R: tauri::Runtime>(
|
||||||
_app: tauri::AppHandle<R>,
|
_app: tauri::AppHandle<R>,
|
||||||
state: State<'_, VectorDBState>,
|
state: State<'_, VectorDBState>,
|
||||||
collection: String,
|
collection: String,
|
||||||
chunks: Vec<ChunkInput>,
|
file_id: String,
|
||||||
|
chunks: Vec<MinimalChunkInput>,
|
||||||
) -> Result<(), VectorDBError> {
|
) -> Result<(), VectorDBError> {
|
||||||
let path = db::collection_path(&state.base_dir, &collection);
|
let path = db::collection_path(&state.base_dir, &collection);
|
||||||
let conn = db::open_or_init_conn(&path)?;
|
let conn = db::open_or_init_conn(&path)?;
|
||||||
let vec_loaded = db::try_load_sqlite_vec(&conn);
|
let vec_loaded = db::try_load_sqlite_vec(&conn);
|
||||||
db::insert_chunks(&conn, chunks, vec_loaded)?;
|
db::insert_chunks(&conn, &file_id, chunks, vec_loaded)
|
||||||
Ok(())
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_file<R: tauri::Runtime>(
|
||||||
|
_app: tauri::AppHandle<R>,
|
||||||
|
state: State<'_, VectorDBState>,
|
||||||
|
collection: String,
|
||||||
|
file_id: String,
|
||||||
|
) -> Result<(), VectorDBError> {
|
||||||
|
let path = db::collection_path(&state.base_dir, &collection);
|
||||||
|
let conn = db::open_or_init_conn(&path)?;
|
||||||
|
db::delete_file(&conn, &file_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@ -2,7 +2,6 @@ use crate::VectorDBError;
|
|||||||
use crate::utils::{cosine_similarity, from_le_bytes_vec, to_le_bytes_vec};
|
use crate::utils::{cosine_similarity, from_le_bytes_vec, to_le_bytes_vec};
|
||||||
use rusqlite::{params, Connection, OptionalExtension};
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -16,18 +15,6 @@ pub struct FileMetadata {
|
|||||||
pub size: Option<i64>,
|
pub size: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
||||||
pub struct ChunkMetadata {
|
|
||||||
pub file: FileMetadata,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct ChunkInput {
|
|
||||||
pub id: Option<String>,
|
|
||||||
pub text: String,
|
|
||||||
pub embedding: Vec<f32>,
|
|
||||||
pub metadata: Option<ChunkMetadata>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct SearchResult {
|
pub struct SearchResult {
|
||||||
@ -49,6 +36,13 @@ pub struct AttachmentFileInfo {
|
|||||||
pub chunk_count: i64,
|
pub chunk_count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New minimal chunk input (no id/metadata) for file-scoped insertion
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct MinimalChunkInput {
|
||||||
|
pub text: String,
|
||||||
|
pub embedding: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Connection & Path Management
|
// Connection & Path Management
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -190,14 +184,84 @@ pub fn create_schema(conn: &Connection, dimension: usize) -> Result<bool, Vector
|
|||||||
// Insert Operations
|
// Insert Operations
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
pub fn create_file(
|
||||||
|
conn: &Connection,
|
||||||
|
path: &str,
|
||||||
|
name: Option<&str>,
|
||||||
|
file_type: Option<&str>,
|
||||||
|
size: Option<i64>,
|
||||||
|
) -> Result<AttachmentFileInfo, VectorDBError> {
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
|
||||||
|
// Try get existing by path
|
||||||
|
if let Ok(Some(id)) = tx
|
||||||
|
.prepare("SELECT id FROM files WHERE path = ?1")
|
||||||
|
.and_then(|mut s| s.query_row(params![path], |r| r.get::<_, String>(0)).optional())
|
||||||
|
{
|
||||||
|
let row: AttachmentFileInfo = {
|
||||||
|
let mut stmt = tx.prepare(
|
||||||
|
"SELECT id, path, name, type, size, chunk_count FROM files WHERE id = ?1",
|
||||||
|
)?;
|
||||||
|
stmt.query_row(params![id.as_str()], |r| {
|
||||||
|
Ok(AttachmentFileInfo {
|
||||||
|
id: r.get(0)?,
|
||||||
|
path: r.get(1)?,
|
||||||
|
name: r.get(2)?,
|
||||||
|
file_type: r.get(3)?,
|
||||||
|
size: r.get(4)?,
|
||||||
|
chunk_count: r.get(5)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
tx.commit()?;
|
||||||
|
return Ok(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_id = Uuid::new_v4().to_string();
|
||||||
|
// Determine file size if not provided
|
||||||
|
let computed_size: Option<i64> = match size {
|
||||||
|
Some(s) if s > 0 => Some(s),
|
||||||
|
_ => {
|
||||||
|
match std::fs::metadata(path) {
|
||||||
|
Ok(meta) => Some(meta.len() as i64),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO files (id, path, name, type, size, chunk_count) VALUES (?1, ?2, ?3, ?4, ?5, 0)",
|
||||||
|
params![new_id, path, name, file_type, computed_size],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let row: AttachmentFileInfo = {
|
||||||
|
let mut stmt = tx.prepare(
|
||||||
|
"SELECT id, path, name, type, size, chunk_count FROM files WHERE path = ?1",
|
||||||
|
)?;
|
||||||
|
stmt.query_row(params![path], |r| {
|
||||||
|
Ok(AttachmentFileInfo {
|
||||||
|
id: r.get(0)?,
|
||||||
|
path: r.get(1)?,
|
||||||
|
name: r.get(2)?,
|
||||||
|
file_type: r.get(3)?,
|
||||||
|
size: r.get(4)?,
|
||||||
|
chunk_count: r.get(5)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn insert_chunks(
|
pub fn insert_chunks(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
chunks: Vec<ChunkInput>,
|
file_id: &str,
|
||||||
|
chunks: Vec<MinimalChunkInput>,
|
||||||
vec_loaded: bool,
|
vec_loaded: bool,
|
||||||
) -> Result<(), VectorDBError> {
|
) -> Result<(), VectorDBError> {
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
|
||||||
// Check if vec virtual table exists
|
// Check if vec table exists
|
||||||
let has_vec = if vec_loaded {
|
let has_vec = if vec_loaded {
|
||||||
conn
|
conn
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunks_vec'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunks_vec'")
|
||||||
@ -209,69 +273,22 @@ pub fn insert_chunks(
|
|||||||
false
|
false
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut file_id_cache: HashMap<String, String> = HashMap::new();
|
// Determine current max order
|
||||||
let mut file_chunk_counters: HashMap<String, i64> = HashMap::new();
|
let mut current_order: i64 = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT COALESCE(MAX(chunk_file_order), -1) FROM chunks WHERE file_id = ?1",
|
||||||
|
params![file_id],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(-1);
|
||||||
|
|
||||||
for ch in chunks.into_iter() {
|
for ch in chunks.into_iter() {
|
||||||
|
current_order += 1;
|
||||||
let emb = to_le_bytes_vec(&ch.embedding);
|
let emb = to_le_bytes_vec(&ch.embedding);
|
||||||
|
let chunk_id = Uuid::new_v4().to_string();
|
||||||
// Extract file info from metadata and get/create file_id
|
|
||||||
let mut file_id: Option<String> = None;
|
|
||||||
if let Some(ref meta) = ch.metadata {
|
|
||||||
let file_path = &meta.file.path;
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
if let Some(cached_id) = file_id_cache.get(file_path) {
|
|
||||||
file_id = Some(cached_id.clone());
|
|
||||||
} else {
|
|
||||||
// Generate UUID for new file
|
|
||||||
let uuid = Uuid::new_v4().to_string();
|
|
||||||
|
|
||||||
// Insert or ignore if path already exists
|
|
||||||
tx.execute(
|
|
||||||
"INSERT OR IGNORE INTO files (id, path, name, type, size) VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
||||||
params![
|
|
||||||
&uuid,
|
|
||||||
&meta.file.path,
|
|
||||||
&meta.file.name,
|
|
||||||
&meta.file.file_type,
|
|
||||||
meta.file.size
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// Get the actual id (either the one we just inserted or existing one)
|
|
||||||
let id: String = tx.query_row(
|
|
||||||
"SELECT id FROM files WHERE path = ?1",
|
|
||||||
params![file_path],
|
|
||||||
|row| row.get(0),
|
|
||||||
)?;
|
|
||||||
file_id = Some(id.clone());
|
|
||||||
file_id_cache.insert(file_path.clone(), id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or initialize chunk order for this file
|
|
||||||
let chunk_order = if let Some(ref fid) = file_id {
|
|
||||||
let counter = file_chunk_counters.entry(fid.clone()).or_insert_with(|| {
|
|
||||||
// Get max existing order for this file
|
|
||||||
tx.query_row(
|
|
||||||
"SELECT COALESCE(MAX(chunk_file_order), -1) FROM chunks WHERE file_id = ?1",
|
|
||||||
params![fid],
|
|
||||||
|row| row.get::<_, i64>(0),
|
|
||||||
).unwrap_or(-1)
|
|
||||||
});
|
|
||||||
*counter += 1;
|
|
||||||
*counter
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generate UUID for chunk if not provided
|
|
||||||
let chunk_id = ch.id.unwrap_or_else(|| Uuid::new_v4().to_string());
|
|
||||||
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT OR REPLACE INTO chunks (id, text, embedding, file_id, chunk_file_order) VALUES (?1, ?2, ?3, ?4, ?5)",
|
"INSERT OR REPLACE INTO chunks (id, text, embedding, file_id, chunk_file_order) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
params![chunk_id, ch.text, emb, file_id, chunk_order],
|
params![chunk_id, ch.text, emb, file_id, current_order],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
if has_vec {
|
if has_vec {
|
||||||
@ -279,35 +296,36 @@ pub fn insert_chunks(
|
|||||||
.prepare("SELECT rowid FROM chunks WHERE id=?1")?
|
.prepare("SELECT rowid FROM chunks WHERE id=?1")?
|
||||||
.query_row(params![chunk_id], |r| r.get(0))?;
|
.query_row(params![chunk_id], |r| r.get(0))?;
|
||||||
let json_vec = serde_json::to_string(&ch.embedding).unwrap_or("[]".to_string());
|
let json_vec = serde_json::to_string(&ch.embedding).unwrap_or("[]".to_string());
|
||||||
match tx.execute(
|
let _ = tx.execute(
|
||||||
"INSERT OR REPLACE INTO chunks_vec(rowid, embedding) VALUES (?1, ?2)",
|
"INSERT OR REPLACE INTO chunks_vec(rowid, embedding) VALUES (?1, ?2)",
|
||||||
params![rowid, json_vec],
|
params![rowid, json_vec],
|
||||||
) {
|
);
|
||||||
Ok(_) => {}
|
|
||||||
Err(e) => {
|
|
||||||
println!("[VectorDB] ✗ Failed to insert into chunks_vec: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update chunk_count for all affected files
|
// Update chunk_count
|
||||||
for file_id in file_id_cache.values() {
|
let count: i64 = tx.query_row(
|
||||||
let count: i64 = tx.query_row(
|
"SELECT COUNT(*) FROM chunks WHERE file_id = ?1",
|
||||||
"SELECT COUNT(*) FROM chunks WHERE file_id = ?1",
|
params![file_id],
|
||||||
params![file_id],
|
|row| row.get(0),
|
||||||
|row| row.get(0),
|
)?;
|
||||||
)?;
|
tx.execute(
|
||||||
tx.execute(
|
"UPDATE files SET chunk_count = ?1 WHERE id = ?2",
|
||||||
"UPDATE files SET chunk_count = ?1 WHERE id = ?2",
|
params![count, file_id],
|
||||||
params![count, file_id],
|
)?;
|
||||||
)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn delete_file(conn: &Connection, file_id: &str) -> Result<(), VectorDBError> {
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
tx.execute("DELETE FROM chunks WHERE file_id = ?1", params![file_id])?;
|
||||||
|
tx.execute("DELETE FROM files WHERE id = ?1", params![file_id])?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Search Operations
|
// Search Operations
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -18,8 +18,10 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::create_collection,
|
commands::create_collection,
|
||||||
commands::insert_chunks,
|
commands::insert_chunks,
|
||||||
|
commands::create_file,
|
||||||
commands::search_collection,
|
commands::search_collection,
|
||||||
commands::delete_chunks,
|
commands::delete_chunks,
|
||||||
|
commands::delete_file,
|
||||||
commands::delete_collection,
|
commands::delete_collection,
|
||||||
commands::chunk_text,
|
commands::chunk_text,
|
||||||
commands::get_status,
|
commands::get_status,
|
||||||
|
|||||||
@ -496,6 +496,9 @@ async fn schedule_mcp_start_task<R: Runtime>(
|
|||||||
client_info: Implementation {
|
client_info: Implementation {
|
||||||
name: "Jan Streamable Client".to_string(),
|
name: "Jan Streamable Client".to_string(),
|
||||||
version: "0.0.1".to_string(),
|
version: "0.0.1".to_string(),
|
||||||
|
title: None,
|
||||||
|
website_url: None,
|
||||||
|
icons: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let client = client_info.serve(transport).await.inspect_err(|e| {
|
let client = client_info.serve(transport).await.inspect_err(|e| {
|
||||||
@ -567,6 +570,9 @@ async fn schedule_mcp_start_task<R: Runtime>(
|
|||||||
client_info: Implementation {
|
client_info: Implementation {
|
||||||
name: "Jan SSE Client".to_string(),
|
name: "Jan SSE Client".to_string(),
|
||||||
version: "0.0.1".to_string(),
|
version: "0.0.1".to_string(),
|
||||||
|
title: None,
|
||||||
|
website_url: None,
|
||||||
|
icons: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let client = client_info.serve(transport).await.map_err(|e| {
|
let client = client_info.serve(transport).await.map_err(|e| {
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import {
|
|||||||
IconX,
|
IconX,
|
||||||
IconPaperclip,
|
IconPaperclip,
|
||||||
IconLoader2,
|
IconLoader2,
|
||||||
|
IconCheck,
|
||||||
} from '@tabler/icons-react'
|
} from '@tabler/icons-react'
|
||||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||||
import { useGeneralSetting } from '@/hooks/useGeneralSetting'
|
import { useGeneralSetting } from '@/hooks/useGeneralSetting'
|
||||||
@ -40,7 +41,7 @@ import { TokenCounter } from '@/components/TokenCounter'
|
|||||||
import { useMessages } from '@/hooks/useMessages'
|
import { useMessages } from '@/hooks/useMessages'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { McpExtensionToolLoader } from './McpExtensionToolLoader'
|
import { McpExtensionToolLoader } from './McpExtensionToolLoader'
|
||||||
import { ExtensionTypeEnum, MCPExtension, RAGExtension } from '@janhq/core'
|
import { ExtensionTypeEnum, MCPExtension, fs, RAGExtension } from '@janhq/core'
|
||||||
import { ExtensionManager } from '@/lib/extension'
|
import { ExtensionManager } from '@/lib/extension'
|
||||||
import { useAttachments } from '@/hooks/useAttachments'
|
import { useAttachments } from '@/hooks/useAttachments'
|
||||||
import { open } from '@tauri-apps/plugin-dialog'
|
import { open } from '@tauri-apps/plugin-dialog'
|
||||||
@ -49,6 +50,7 @@ import { PlatformFeatures } from '@/lib/platform/const'
|
|||||||
import { PlatformFeature } from '@/lib/platform/types'
|
import { PlatformFeature } from '@/lib/platform/types'
|
||||||
import { useAnalytic } from '@/hooks/useAnalytic'
|
import { useAnalytic } from '@/hooks/useAnalytic'
|
||||||
import posthog from 'posthog-js'
|
import posthog from 'posthog-js'
|
||||||
|
import { Attachment, createImageAttachment, createDocumentAttachment } from '@/types/attachment'
|
||||||
|
|
||||||
type ChatInputProps = {
|
type ChatInputProps = {
|
||||||
className?: string
|
className?: string
|
||||||
@ -101,33 +103,20 @@ const ChatInput = ({
|
|||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const [dropdownToolsAvailable, setDropdownToolsAvailable] = useState(false)
|
const [dropdownToolsAvailable, setDropdownToolsAvailable] = useState(false)
|
||||||
const [tooltipToolsAvailable, setTooltipToolsAvailable] = useState(false)
|
const [tooltipToolsAvailable, setTooltipToolsAvailable] = useState(false)
|
||||||
const [uploadedFiles, setUploadedFiles] = useState<
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||||
Array<{
|
|
||||||
name: string
|
|
||||||
type: string
|
|
||||||
size: number
|
|
||||||
base64: string
|
|
||||||
dataUrl: string
|
|
||||||
}>
|
|
||||||
>([])
|
|
||||||
// Document attachments (desktop RAG ingestion). We only index on send.
|
|
||||||
const [docFiles, setDocFiles] = useState<
|
|
||||||
Array<{
|
|
||||||
name: string
|
|
||||||
path: string
|
|
||||||
size?: number
|
|
||||||
type?: string
|
|
||||||
}>
|
|
||||||
>([])
|
|
||||||
const [connectedServers, setConnectedServers] = useState<string[]>([])
|
const [connectedServers, setConnectedServers] = useState<string[]>([])
|
||||||
const [isDragOver, setIsDragOver] = useState(false)
|
const [isDragOver, setIsDragOver] = useState(false)
|
||||||
const [hasMmproj, setHasMmproj] = useState(false)
|
const [hasMmproj, setHasMmproj] = useState(false)
|
||||||
const [hasActiveModels, setHasActiveModels] = useState(false)
|
const [hasActiveModels, setHasActiveModels] = useState(false)
|
||||||
const attachmentsEnabled = useAttachments((s) => s.enabled)
|
const attachmentsEnabled = useAttachments((s) => s.enabled)
|
||||||
const [ingestingDocs, setIngestingDocs] = useState(false)
|
|
||||||
// Determine whether to show the Attach documents button (simple gating)
|
// Determine whether to show the Attach documents button (simple gating)
|
||||||
const showAttachmentButton =
|
const showAttachmentButton =
|
||||||
attachmentsEnabled && PlatformFeatures[PlatformFeature.ATTACHMENTS]
|
attachmentsEnabled && PlatformFeatures[PlatformFeature.ATTACHMENTS]
|
||||||
|
// Derived: any document currently processing (ingestion in progress)
|
||||||
|
const ingestingDocs = attachments.some(
|
||||||
|
(a) => a.type === 'document' && a.processing
|
||||||
|
)
|
||||||
|
const ingestingAny = attachments.some((a) => a.processing)
|
||||||
|
|
||||||
// Check for connected MCP servers
|
// Check for connected MCP servers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -208,36 +197,10 @@ const ChatInput = ({
|
|||||||
setMessage('Please select a model to start chatting.')
|
setMessage('Please select a model to start chatting.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!prompt.trim() && uploadedFiles.length === 0 && docFiles.length === 0) {
|
if (!prompt.trim()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// If we have pending doc files, index them first
|
|
||||||
if (docFiles.length > 0) {
|
|
||||||
try {
|
|
||||||
setIngestingDocs(true)
|
|
||||||
const rag = extensionManager.get<RAGExtension>(ExtensionTypeEnum.RAG)
|
|
||||||
if (!rag?.ingestAttachments) throw new Error('Retrieval extension not available')
|
|
||||||
for (const f of docFiles) {
|
|
||||||
const id = (toast as any).loading
|
|
||||||
? (toast as any).loading(`Indexing ${f.name || f.path}…`)
|
|
||||||
: undefined
|
|
||||||
try {
|
|
||||||
await rag.ingestAttachments(currentThreadId!, [{ path: f.path, name: f.name }])
|
|
||||||
if (id) toast.success(`Indexed ${f.name || f.path}`, { id })
|
|
||||||
} catch (err) {
|
|
||||||
if (id) toast.error(`Failed to index ${f.name || f.path}`, { id })
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setDocFiles([])
|
|
||||||
} catch (err) {
|
|
||||||
const desc = err instanceof Error ? err.message : String(err)
|
|
||||||
toast.error('Failed to index attachments', { description: desc })
|
|
||||||
setIngestingDocs(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setIngestingDocs(false)
|
|
||||||
}
|
|
||||||
setMessage('')
|
setMessage('')
|
||||||
|
|
||||||
// Track message send event with PostHog (only if product analytics is enabled)
|
// Track message send event with PostHog (only if product analytics is enabled)
|
||||||
@ -252,13 +215,39 @@ const ChatInput = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Callback to update attachment processing state
|
||||||
|
const updateAttachmentProcessing = (
|
||||||
|
fileName: string,
|
||||||
|
status: 'processing' | 'done' | 'error' | 'clear_docs' | 'clear_all'
|
||||||
|
) => {
|
||||||
|
if (status === 'clear_docs') {
|
||||||
|
setAttachments((prev) => prev.filter((a) => a.type !== 'document'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (status === 'clear_all') {
|
||||||
|
setAttachments([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAttachments((prev) =>
|
||||||
|
prev.map((att) =>
|
||||||
|
att.name === fileName
|
||||||
|
? {
|
||||||
|
...att,
|
||||||
|
processing: status === 'processing',
|
||||||
|
processed: status === 'done' ? true : att.processed,
|
||||||
|
}
|
||||||
|
: att
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
sendMessage(
|
sendMessage(
|
||||||
prompt,
|
prompt,
|
||||||
true,
|
true,
|
||||||
uploadedFiles.length > 0 ? uploadedFiles : undefined,
|
attachments.length > 0 ? attachments : undefined,
|
||||||
projectId
|
projectId,
|
||||||
|
updateAttachmentProcessing
|
||||||
)
|
)
|
||||||
setUploadedFiles([])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -333,10 +322,6 @@ const ChatInput = ({
|
|||||||
toast.info('Attachments are disabled in Settings')
|
toast.info('Attachments are disabled in Settings')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!currentThreadId) {
|
|
||||||
toast.info('Please start a thread first to attach documents.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const selection = await open({
|
const selection = await open({
|
||||||
multiple: true,
|
multiple: true,
|
||||||
filters: [
|
filters: [
|
||||||
@ -349,24 +334,128 @@ const ChatInput = ({
|
|||||||
if (!selection) return
|
if (!selection) return
|
||||||
const paths = Array.isArray(selection) ? selection : [selection]
|
const paths = Array.isArray(selection) ? selection : [selection]
|
||||||
if (!paths.length) return
|
if (!paths.length) return
|
||||||
setDocFiles((prev) => [
|
|
||||||
...prev,
|
// Check for duplicates and fetch file sizes
|
||||||
...paths.map((p) => ({
|
const existingPaths = new Set(
|
||||||
path: p,
|
attachments
|
||||||
name: p.split(/[\\/]/).pop() || p,
|
.filter((a) => a.type === 'document' && a.path)
|
||||||
})),
|
.map((a) => a.path)
|
||||||
])
|
)
|
||||||
|
|
||||||
|
const duplicates: string[] = []
|
||||||
|
const newDocAttachments: Attachment[] = []
|
||||||
|
|
||||||
|
for (const p of paths) {
|
||||||
|
if (existingPaths.has(p)) {
|
||||||
|
duplicates.push(p.split(/[\\/]/).pop() || p)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = p.split(/[\\/]/).pop() || p
|
||||||
|
const fileType = name.split('.').pop()?.toLowerCase()
|
||||||
|
let size: number | undefined = undefined
|
||||||
|
try {
|
||||||
|
const stat = await fs.fileStat(p)
|
||||||
|
size = stat?.size ? Number(stat.size) : undefined
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to read file size for', p, e)
|
||||||
|
}
|
||||||
|
newDocAttachments.push(
|
||||||
|
createDocumentAttachment({
|
||||||
|
name,
|
||||||
|
path: p,
|
||||||
|
fileType,
|
||||||
|
size,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
toast.warning('Files already attached', {
|
||||||
|
description: `${duplicates.join(', ')} ${duplicates.length === 1 ? 'is' : 'are'} already in the list`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newDocAttachments.length > 0) {
|
||||||
|
// Add to state first with processing flag
|
||||||
|
setAttachments((prev) => [...prev, ...newDocAttachments])
|
||||||
|
|
||||||
|
// If thread exists, ingest immediately
|
||||||
|
if (currentThreadId) {
|
||||||
|
const ragExtension = ExtensionManager.getInstance().get(
|
||||||
|
ExtensionTypeEnum.RAG
|
||||||
|
) as RAGExtension | undefined
|
||||||
|
if (!ragExtension) {
|
||||||
|
toast.error('RAG extension not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingest each document
|
||||||
|
for (const doc of newDocAttachments) {
|
||||||
|
try {
|
||||||
|
// Mark as processing
|
||||||
|
setAttachments((prev) =>
|
||||||
|
prev.map((a) =>
|
||||||
|
a.path === doc.path && a.type === 'document'
|
||||||
|
? { ...a, processing: true }
|
||||||
|
: a
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await ragExtension.ingestAttachments(
|
||||||
|
currentThreadId,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
path: doc.path!,
|
||||||
|
name: doc.name,
|
||||||
|
type: doc.fileType,
|
||||||
|
size: doc.size,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const fileInfo = result.files?.[0]
|
||||||
|
if (fileInfo?.id) {
|
||||||
|
// Mark as processed with ID
|
||||||
|
setAttachments((prev) =>
|
||||||
|
prev.map((a) =>
|
||||||
|
a.path === doc.path && a.type === 'document'
|
||||||
|
? {
|
||||||
|
...a,
|
||||||
|
processing: false,
|
||||||
|
processed: true,
|
||||||
|
id: fileInfo.id,
|
||||||
|
chunkCount: fileInfo.chunk_count,
|
||||||
|
}
|
||||||
|
: a
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
throw new Error('No file ID returned from ingestion')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to ingest document:', error)
|
||||||
|
// Remove failed document
|
||||||
|
setAttachments((prev) =>
|
||||||
|
prev.filter((a) => !(a.path === doc.path && a.type === 'document'))
|
||||||
|
)
|
||||||
|
toast.error(`Failed to ingest ${doc.name}`, {
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to ingest attachments:', e)
|
console.error('Failed to attach documents:', e)
|
||||||
const desc = e instanceof Error ? e.message : String(e)
|
const desc = e instanceof Error ? e.message : String(e)
|
||||||
toast.error('Failed to attach documents', { description: desc })
|
toast.error('Failed to attach documents', { description: desc })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRemoveFile = (indexToRemove: number) => {
|
const handleRemoveAttachment = (indexToRemove: number) => {
|
||||||
setUploadedFiles((prev) =>
|
setAttachments((prev) => prev.filter((_, index) => index !== indexToRemove))
|
||||||
prev.filter((_, index) => index !== indexToRemove)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFileTypeFromExtension = (fileName: string): string => {
|
const getFileTypeFromExtension = (fileName: string): string => {
|
||||||
@ -382,20 +471,39 @@ const ChatInput = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatBytes = (bytes?: number): string => {
|
||||||
|
if (!bytes || bytes <= 0) return ''
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
|
let i = 0
|
||||||
|
let val = bytes
|
||||||
|
while (val >= 1024 && i < units.length - 1) {
|
||||||
|
val /= 1024
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files
|
const files = e.target.files
|
||||||
|
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
const maxSize = 10 * 1024 * 1024 // 10MB in bytes
|
const maxSize = 10 * 1024 * 1024 // 10MB in bytes
|
||||||
const newFiles: Array<{
|
const newFiles: Attachment[] = []
|
||||||
name: string
|
const duplicates: string[] = []
|
||||||
type: string
|
const existingImageNames = new Set(
|
||||||
size: number
|
attachments
|
||||||
base64: string
|
.filter((a) => a.type === 'image')
|
||||||
dataUrl: string
|
.map((a) => a.name)
|
||||||
}> = []
|
)
|
||||||
|
|
||||||
Array.from(files).forEach((file) => {
|
Array.from(files).forEach((file) => {
|
||||||
|
// Check for duplicate image names
|
||||||
|
if (existingImageNames.has(file.name)) {
|
||||||
|
duplicates.push(file.name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Check file size
|
// Check file size
|
||||||
if (file.size > maxSize) {
|
if (file.size > maxSize) {
|
||||||
setMessage(`File is too large. Maximum size is 10MB.`)
|
setMessage(`File is too large. Maximum size is 10MB.`)
|
||||||
@ -429,26 +537,92 @@ const ChatInput = ({
|
|||||||
const result = reader.result
|
const result = reader.result
|
||||||
if (typeof result === 'string') {
|
if (typeof result === 'string') {
|
||||||
const base64String = result.split(',')[1]
|
const base64String = result.split(',')[1]
|
||||||
const fileData = {
|
const att = createImageAttachment({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
type: actualType,
|
mimeType: actualType,
|
||||||
base64: base64String,
|
base64: base64String,
|
||||||
dataUrl: result,
|
dataUrl: result,
|
||||||
}
|
})
|
||||||
newFiles.push(fileData)
|
newFiles.push(att)
|
||||||
// Update state
|
// Update state
|
||||||
if (
|
if (
|
||||||
newFiles.length ===
|
newFiles.length ===
|
||||||
Array.from(files).filter((f) => {
|
Array.from(files).filter((f) => {
|
||||||
const fType = getFileTypeFromExtension(f.name) || f.type
|
const fType = getFileTypeFromExtension(f.name) || f.type
|
||||||
return f.size <= maxSize && allowedTypes.includes(fType)
|
return (
|
||||||
|
f.size <= maxSize &&
|
||||||
|
allowedTypes.includes(fType) &&
|
||||||
|
!existingImageNames.has(f.name)
|
||||||
|
)
|
||||||
}).length
|
}).length
|
||||||
) {
|
) {
|
||||||
setUploadedFiles((prev) => {
|
if (newFiles.length > 0) {
|
||||||
const updated = [...prev, ...newFiles]
|
setAttachments((prev) => {
|
||||||
return updated
|
const updated = [...prev, ...newFiles]
|
||||||
})
|
return updated
|
||||||
|
})
|
||||||
|
|
||||||
|
// If thread exists, ingest images immediately
|
||||||
|
if (currentThreadId) {
|
||||||
|
;(async () => {
|
||||||
|
for (const img of newFiles) {
|
||||||
|
try {
|
||||||
|
// Mark as processing
|
||||||
|
setAttachments((prev) =>
|
||||||
|
prev.map((a) =>
|
||||||
|
a.name === img.name && a.type === 'image'
|
||||||
|
? { ...a, processing: true }
|
||||||
|
: a
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await serviceHub.uploads().ingestImage(
|
||||||
|
currentThreadId,
|
||||||
|
img
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result?.id) {
|
||||||
|
// Mark as processed with ID
|
||||||
|
setAttachments((prev) =>
|
||||||
|
prev.map((a) =>
|
||||||
|
a.name === img.name && a.type === 'image'
|
||||||
|
? {
|
||||||
|
...a,
|
||||||
|
processing: false,
|
||||||
|
processed: true,
|
||||||
|
id: result.id,
|
||||||
|
}
|
||||||
|
: a
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
throw new Error('No ID returned from image ingestion')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to ingest image:', error)
|
||||||
|
// Remove failed image
|
||||||
|
setAttachments((prev) =>
|
||||||
|
prev.filter(
|
||||||
|
(a) => !(a.name === img.name && a.type === 'image')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
toast.error(`Failed to ingest ${img.name}`, {
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
toast.warning('Some images already attached', {
|
||||||
|
description: `${duplicates.join(', ')} ${duplicates.length === 1 ? 'is' : 'are'} already in the list`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Reset the file input value to allow re-uploading the same file
|
// Reset the file input value to allow re-uploading the same file
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = ''
|
fileInputRef.current.value = ''
|
||||||
@ -662,54 +836,90 @@ const ChatInput = ({
|
|||||||
onDragOver={hasMmproj ? handleDragOver : undefined}
|
onDragOver={hasMmproj ? handleDragOver : undefined}
|
||||||
onDrop={hasMmproj ? handleDrop : undefined}
|
onDrop={hasMmproj ? handleDrop : undefined}
|
||||||
>
|
>
|
||||||
{(uploadedFiles.length > 0 || docFiles.length > 0) && (
|
{attachments.length > 0 && (
|
||||||
<div className="flex gap-3 items-center p-2 pb-0">
|
<div className="flex gap-3 items-center p-2 pb-0">
|
||||||
{uploadedFiles.map((file, index) => {
|
{attachments
|
||||||
return (
|
.map((att, idx) => ({ att, idx }))
|
||||||
<div
|
.map(({ att, idx }) => {
|
||||||
key={index}
|
const isImage = att.type === 'image'
|
||||||
className={cn(
|
const ext = att.fileType || att.mimeType?.split('/')[1]
|
||||||
'relative border border-main-view-fg/5 rounded-lg',
|
return (
|
||||||
file.type.startsWith('image/') ? 'size-14' : 'h-14 '
|
<div key={`${att.type}-${idx}-${att.name}`} className="relative">
|
||||||
)}
|
<TooltipProvider>
|
||||||
>
|
<Tooltip>
|
||||||
{file.type.startsWith('image/') && (
|
<TooltipTrigger asChild>
|
||||||
<img
|
<div
|
||||||
className="object-cover w-full h-full rounded-lg"
|
className={cn(
|
||||||
src={file.dataUrl}
|
'relative border border-main-view-fg/5 rounded-lg size-14 overflow-hidden bg-main-view/40',
|
||||||
alt={`${file.name} - ${index}`}
|
'flex items-center justify-center'
|
||||||
/>
|
)}
|
||||||
)}
|
>
|
||||||
<div
|
{/* Inner content by state */}
|
||||||
className="absolute -top-1 -right-2.5 bg-destructive size-5 flex rounded-full items-center justify-center cursor-pointer"
|
{isImage && att.dataUrl ? (
|
||||||
onClick={() => handleRemoveFile(index)}
|
<img
|
||||||
>
|
className="object-cover w-full h-full"
|
||||||
<IconX className="text-destructive-fg" size={16} />
|
src={att.dataUrl}
|
||||||
|
alt={`${att.name}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center text-main-view-fg/70">
|
||||||
|
<IconPaperclip size={18} />
|
||||||
|
{ext && (
|
||||||
|
<span className="text-[10px] leading-none mt-0.5 uppercase opacity-70">
|
||||||
|
.{ext}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overlay spinner when processing */}
|
||||||
|
{att.processing && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/10">
|
||||||
|
<IconLoader2
|
||||||
|
size={18}
|
||||||
|
className="text-main-view-fg/80 animate-spin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overlay success check when processed */}
|
||||||
|
{att.processed && !att.processing && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/5">
|
||||||
|
<div className="bg-green-600/90 rounded-full p-1">
|
||||||
|
<IconCheck size={14} className="text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<div className="text-xs">
|
||||||
|
<div className="font-medium truncate max-w-52" title={att.name}>
|
||||||
|
{att.name}
|
||||||
|
</div>
|
||||||
|
<div className="opacity-70">
|
||||||
|
{isImage
|
||||||
|
? (att.mimeType || 'image')
|
||||||
|
: (ext ? `.${ext}` : 'document')}
|
||||||
|
{att.size ? ` · ${formatBytes(att.size)}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
{/* Remove button disabled while processing - outside overflow-hidden container */}
|
||||||
|
{!att.processing && (
|
||||||
|
<div
|
||||||
|
className="absolute -top-1 -right-2.5 bg-destructive size-5 flex rounded-full items-center justify-center cursor-pointer"
|
||||||
|
onClick={() => handleRemoveAttachment(idx)}
|
||||||
|
>
|
||||||
|
<IconX className="text-destructive-fg" size={16} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
})}
|
||||||
})}
|
|
||||||
{docFiles.map((file, index) => (
|
|
||||||
<div
|
|
||||||
key={`doc-${index}`}
|
|
||||||
className="relative border border-main-view-fg/5 rounded-lg px-2 py-1 text-xs flex items-center gap-2 bg-main-view/40"
|
|
||||||
>
|
|
||||||
<IconPaperclip size={14} className="text-main-view-fg/50" />
|
|
||||||
<span className="max-w-48 truncate" title={file.name}>
|
|
||||||
{file.name}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className="absolute -top-1 -right-2.5 bg-destructive size-5 flex rounded-full items-center justify-center cursor-pointer"
|
|
||||||
onClick={() =>
|
|
||||||
setDocFiles((prev) =>
|
|
||||||
prev.filter((_, i) => i !== index)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconX className="text-destructive-fg" size={16} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<TextareaAutosize
|
<TextareaAutosize
|
||||||
@ -975,7 +1185,15 @@ const ChatInput = ({
|
|||||||
<TokenCounter
|
<TokenCounter
|
||||||
messages={threadMessages || []}
|
messages={threadMessages || []}
|
||||||
compact={true}
|
compact={true}
|
||||||
uploadedFiles={uploadedFiles}
|
uploadedFiles={attachments
|
||||||
|
.filter((a) => a.type === 'image' && a.dataUrl)
|
||||||
|
.map((a) => ({
|
||||||
|
name: a.name,
|
||||||
|
type: a.mimeType || getFileTypeFromExtension(a.name),
|
||||||
|
size: a.size || 0,
|
||||||
|
base64: a.base64 || '',
|
||||||
|
dataUrl: a.dataUrl!,
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -992,24 +1210,13 @@ const ChatInput = ({
|
|||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant={
|
variant={!prompt.trim() ? null : 'default'}
|
||||||
!prompt.trim() &&
|
|
||||||
uploadedFiles.length === 0 &&
|
|
||||||
docFiles.length === 0
|
|
||||||
? null
|
|
||||||
: 'default'
|
|
||||||
}
|
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={
|
disabled={!prompt.trim() || ingestingAny}
|
||||||
(!prompt.trim() &&
|
|
||||||
uploadedFiles.length === 0 &&
|
|
||||||
docFiles.length === 0) ||
|
|
||||||
ingestingDocs
|
|
||||||
}
|
|
||||||
data-test-id="send-message-button"
|
data-test-id="send-message-button"
|
||||||
onClick={() => handleSendMessage(prompt)}
|
onClick={() => handleSendMessage(prompt)}
|
||||||
>
|
>
|
||||||
{streamingContent || ingestingDocs ? (
|
{streamingContent || ingestingAny ? (
|
||||||
<span className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
|
<span className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
|
||||||
) : (
|
) : (
|
||||||
<ArrowRight className="text-primary-fg" />
|
<ArrowRight className="text-primary-fg" />
|
||||||
@ -1048,7 +1255,15 @@ const ChatInput = ({
|
|||||||
<TokenCounter
|
<TokenCounter
|
||||||
messages={threadMessages || []}
|
messages={threadMessages || []}
|
||||||
compact={false}
|
compact={false}
|
||||||
uploadedFiles={uploadedFiles}
|
uploadedFiles={attachments
|
||||||
|
.filter((a) => a.type === 'image' && a.dataUrl)
|
||||||
|
.map((a) => ({
|
||||||
|
name: a.name,
|
||||||
|
type: a.mimeType || getFileTypeFromExtension(a.name),
|
||||||
|
size: a.size || 0,
|
||||||
|
base64: a.base64 || '',
|
||||||
|
dataUrl: a.dataUrl!,
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -26,6 +26,8 @@ import TokenSpeedIndicator from '@/containers/TokenSpeedIndicator'
|
|||||||
|
|
||||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||||
import { useModelProvider } from '@/hooks/useModelProvider'
|
import { useModelProvider } from '@/hooks/useModelProvider'
|
||||||
|
import { extractFilesFromPrompt } from '@/lib/fileMetadata'
|
||||||
|
import { createImageAttachment } from '@/types/attachment'
|
||||||
|
|
||||||
const CopyButton = ({ text }: { text: string }) => {
|
const CopyButton = ({ text }: { text: string }) => {
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
@ -102,6 +104,14 @@ export const ThreadContent = memo(
|
|||||||
[item.content]
|
[item.content]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Extract file metadata from user message text
|
||||||
|
const { files: attachedFiles, cleanPrompt } = useMemo(() => {
|
||||||
|
if (item.role === 'user') {
|
||||||
|
return extractFilesFromPrompt(text)
|
||||||
|
}
|
||||||
|
return { files: [], cleanPrompt: text }
|
||||||
|
}, [text, item.role])
|
||||||
|
|
||||||
const { reasoningSegment, textSegment } = useMemo(() => {
|
const { reasoningSegment, textSegment } = useMemo(() => {
|
||||||
// Check for thinking formats
|
// Check for thinking formats
|
||||||
const hasThinkTag = text.includes('<think>') && !text.includes('</think>')
|
const hasThinkTag = text.includes('<think>') && !text.includes('</think>')
|
||||||
@ -153,9 +163,9 @@ export const ThreadContent = memo(
|
|||||||
if (toSendMessage) {
|
if (toSendMessage) {
|
||||||
deleteMessage(toSendMessage.thread_id, toSendMessage.id ?? '')
|
deleteMessage(toSendMessage.thread_id, toSendMessage.id ?? '')
|
||||||
// Extract text content and any attachments
|
// Extract text content and any attachments
|
||||||
const textContent =
|
const rawText =
|
||||||
toSendMessage.content?.find((c) => c.type === 'text')?.text?.value ||
|
toSendMessage.content?.find((c) => c.type === 'text')?.text?.value || ''
|
||||||
''
|
const { cleanPrompt: textContent } = extractFilesFromPrompt(rawText)
|
||||||
const attachments = toSendMessage.content
|
const attachments = toSendMessage.content
|
||||||
?.filter((c) => (c.type === 'image_url' && c.image_url?.url) || false)
|
?.filter((c) => (c.type === 'image_url' && c.image_url?.url) || false)
|
||||||
.map((c) => {
|
.map((c) => {
|
||||||
@ -164,24 +174,19 @@ export const ThreadContent = memo(
|
|||||||
const [mimeType, base64] = url
|
const [mimeType, base64] = url
|
||||||
.replace('data:', '')
|
.replace('data:', '')
|
||||||
.split(';base64,')
|
.split(';base64,')
|
||||||
return {
|
return createImageAttachment({
|
||||||
name: 'image', // We don't have the original filename
|
name: 'image', // Original filename unavailable
|
||||||
type: mimeType,
|
mimeType,
|
||||||
size: 0, // We don't have the original size
|
size: 0,
|
||||||
base64: base64,
|
base64: base64,
|
||||||
dataUrl: url,
|
dataUrl: url,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
.filter(Boolean) as Array<{
|
.filter(Boolean)
|
||||||
name: string
|
// Keep embedded document metadata in the message for regenerate
|
||||||
type: string
|
sendMessage(rawText, true, attachments)
|
||||||
size: number
|
|
||||||
base64: string
|
|
||||||
dataUrl: string
|
|
||||||
}>
|
|
||||||
sendMessage(textContent, true, attachments)
|
|
||||||
}
|
}
|
||||||
}, [deleteMessage, getMessages, item, sendMessage])
|
}, [deleteMessage, getMessages, item, sendMessage])
|
||||||
|
|
||||||
@ -225,7 +230,56 @@ export const ThreadContent = memo(
|
|||||||
<Fragment>
|
<Fragment>
|
||||||
{item.role === 'user' && (
|
{item.role === 'user' && (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{/* Render attachments above the message bubble */}
|
{/* Render text content in the message bubble */}
|
||||||
|
{cleanPrompt && (
|
||||||
|
<div className="flex justify-end w-full h-full text-start break-words whitespace-normal">
|
||||||
|
<div className="bg-main-view-fg/4 relative text-main-view-fg p-2 rounded-md inline-block max-w-[80%] ">
|
||||||
|
<div className="select-text">
|
||||||
|
<RenderMarkdown
|
||||||
|
content={cleanPrompt}
|
||||||
|
components={linkComponents}
|
||||||
|
isUser
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Render document file attachments (extracted from message text) - below text */}
|
||||||
|
{attachedFiles.length > 0 && (
|
||||||
|
<div className="flex justify-end w-full mt-2 mb-2">
|
||||||
|
<div className="flex flex-wrap gap-2 max-w-[80%] justify-end">
|
||||||
|
{attachedFiles.map((file, index) => (
|
||||||
|
<div
|
||||||
|
key={file.id || index}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-main-view-fg/5 rounded-md border border-main-view-fg/10 text-xs"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-main-view-fg/50"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-main-view-fg">{file.name}</span>
|
||||||
|
{file.type && (
|
||||||
|
<span className="text-main-view-fg/40 text-[10px]">
|
||||||
|
.{file.type}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Render image attachments - below files */}
|
||||||
{item.content?.some(
|
{item.content?.some(
|
||||||
(c) => (c.type === 'image_url' && c.image_url?.url) || false
|
(c) => (c.type === 'image_url' && c.image_url?.url) || false
|
||||||
) && (
|
) && (
|
||||||
@ -258,33 +312,9 @@ export const ThreadContent = memo(
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Render text content in the message bubble */}
|
|
||||||
{item.content?.some((c) => c.type === 'text' && c.text?.value) && (
|
|
||||||
<div className="flex justify-end w-full h-full text-start break-words whitespace-normal">
|
|
||||||
<div className="bg-main-view-fg/4 relative text-main-view-fg p-2 rounded-md inline-block max-w-[80%] ">
|
|
||||||
<div className="select-text">
|
|
||||||
{item.content
|
|
||||||
?.filter((c) => c.type === 'text' && c.text?.value)
|
|
||||||
.map((contentPart, index) => (
|
|
||||||
<div key={index}>
|
|
||||||
<RenderMarkdown
|
|
||||||
content={contentPart.text!.value}
|
|
||||||
components={linkComponents}
|
|
||||||
isUser
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 text-main-view-fg/60 text-xs mt-2">
|
<div className="flex items-center justify-end gap-2 text-main-view-fg/60 text-xs mt-2">
|
||||||
<EditMessageDialog
|
<EditMessageDialog
|
||||||
message={
|
message={cleanPrompt || ''}
|
||||||
item.content?.find((c) => c.type === 'text')?.text?.value ||
|
|
||||||
''
|
|
||||||
}
|
|
||||||
imageUrls={
|
imageUrls={
|
||||||
item.content
|
item.content
|
||||||
?.filter((c) => c.type === 'image_url' && c.image_url?.url)
|
?.filter((c) => c.type === 'image_url' && c.image_url?.url)
|
||||||
|
|||||||
@ -37,6 +37,8 @@ import {
|
|||||||
import { useAssistant } from './useAssistant'
|
import { useAssistant } from './useAssistant'
|
||||||
import { useShallow } from 'zustand/shallow'
|
import { useShallow } from 'zustand/shallow'
|
||||||
import { TEMPORARY_CHAT_QUERY_ID, TEMPORARY_CHAT_ID } from '@/constants/chat'
|
import { TEMPORARY_CHAT_QUERY_ID, TEMPORARY_CHAT_ID } from '@/constants/chat'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Attachment } from '@/types/attachment'
|
||||||
|
|
||||||
export const useChat = () => {
|
export const useChat = () => {
|
||||||
const [
|
const [
|
||||||
@ -257,14 +259,12 @@ export const useChat = () => {
|
|||||||
async (
|
async (
|
||||||
message: string,
|
message: string,
|
||||||
troubleshooting = true,
|
troubleshooting = true,
|
||||||
attachments?: Array<{
|
attachments?: Attachment[],
|
||||||
name: string
|
projectId?: string,
|
||||||
type: string
|
updateAttachmentProcessing?: (
|
||||||
size: number
|
fileName: string,
|
||||||
base64: string
|
status: 'processing' | 'done' | 'error' | 'clear_docs' | 'clear_all'
|
||||||
dataUrl: string
|
) => void
|
||||||
}>,
|
|
||||||
projectId?: string
|
|
||||||
) => {
|
) => {
|
||||||
const activeThread = await getCurrentThread(projectId)
|
const activeThread = await getCurrentThread(projectId)
|
||||||
const selectedProvider = useModelProvider.getState().selectedProvider
|
const selectedProvider = useModelProvider.getState().selectedProvider
|
||||||
@ -272,14 +272,124 @@ export const useChat = () => {
|
|||||||
|
|
||||||
resetTokenSpeed()
|
resetTokenSpeed()
|
||||||
if (!activeThread || !activeProvider) return
|
if (!activeThread || !activeProvider) return
|
||||||
|
|
||||||
|
// Separate images and documents
|
||||||
|
const images = attachments?.filter((a) => a.type === 'image') || []
|
||||||
|
const documents = attachments?.filter((a) => a.type === 'document') || []
|
||||||
|
|
||||||
|
// Process attachments BEFORE sending
|
||||||
|
const processedAttachments: Attachment[] = []
|
||||||
|
|
||||||
|
// 1) Images ingestion (placeholder/no-op for now)
|
||||||
|
// Track attachment ingestion; all must succeed before sending
|
||||||
|
|
||||||
|
if (images.length > 0) {
|
||||||
|
for (const img of images) {
|
||||||
|
try {
|
||||||
|
// Skip if already processed (ingested in ChatInput when thread existed)
|
||||||
|
if (img.processed && img.id) {
|
||||||
|
processedAttachments.push(img)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateAttachmentProcessing) {
|
||||||
|
updateAttachmentProcessing(img.name, 'processing')
|
||||||
|
}
|
||||||
|
// Upload image, get id/URL
|
||||||
|
const res = await serviceHub.uploads().ingestImage(activeThread.id, img)
|
||||||
|
processedAttachments.push({
|
||||||
|
...img,
|
||||||
|
id: res.id,
|
||||||
|
processed: true,
|
||||||
|
processing: false,
|
||||||
|
})
|
||||||
|
if (updateAttachmentProcessing) {
|
||||||
|
updateAttachmentProcessing(img.name, 'done')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to ingest image ${img.name}:`, err)
|
||||||
|
if (updateAttachmentProcessing) {
|
||||||
|
updateAttachmentProcessing(img.name, 'error')
|
||||||
|
}
|
||||||
|
const desc = err instanceof Error ? err.message : String(err)
|
||||||
|
toast.error('Failed to ingest image attachment', { description: desc })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (documents.length > 0) {
|
||||||
|
try {
|
||||||
|
for (const doc of documents) {
|
||||||
|
// Skip if already processed (ingested in ChatInput when thread existed)
|
||||||
|
if (doc.processed && doc.id) {
|
||||||
|
processedAttachments.push(doc)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update UI to show spinner on this file
|
||||||
|
if (updateAttachmentProcessing) {
|
||||||
|
updateAttachmentProcessing(doc.name, 'processing')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await serviceHub
|
||||||
|
.uploads()
|
||||||
|
.ingestFileAttachment(activeThread.id, doc)
|
||||||
|
|
||||||
|
// Add processed document with ID
|
||||||
|
processedAttachments.push({
|
||||||
|
...doc,
|
||||||
|
id: res.id,
|
||||||
|
size: res.size ?? doc.size,
|
||||||
|
chunkCount: res.chunkCount ?? doc.chunkCount,
|
||||||
|
processing: false,
|
||||||
|
processed: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update UI to show done state
|
||||||
|
if (updateAttachmentProcessing) {
|
||||||
|
updateAttachmentProcessing(doc.name, 'done')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to ingest ${doc.name}:`, err)
|
||||||
|
if (updateAttachmentProcessing) {
|
||||||
|
updateAttachmentProcessing(doc.name, 'error')
|
||||||
|
}
|
||||||
|
throw err // Re-throw to handle in outer catch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to ingest documents:', err)
|
||||||
|
const desc = err instanceof Error ? err.message : String(err)
|
||||||
|
toast.error('Failed to index attachments', { description: desc })
|
||||||
|
// Don't continue with message send if ingestion failed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All attachments prepared successfully
|
||||||
|
|
||||||
const messages = getMessages(activeThread.id)
|
const messages = getMessages(activeThread.id)
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
setAbortController(activeThread.id, abortController)
|
setAbortController(activeThread.id, abortController)
|
||||||
updateStreamingContent(emptyThreadContent)
|
updateStreamingContent(emptyThreadContent)
|
||||||
updatePromptProgress(undefined)
|
updatePromptProgress(undefined)
|
||||||
// Do not add new message on retry
|
// Do not add new message on retry
|
||||||
if (troubleshooting)
|
// All attachments (images + docs) ingested successfully.
|
||||||
addMessage(newUserThreadContent(activeThread.id, message, attachments))
|
// Build the user content once; use it for both the outbound request
|
||||||
|
// and persisting to the store so both are identical.
|
||||||
|
if (updateAttachmentProcessing) {
|
||||||
|
updateAttachmentProcessing('__CLEAR_ALL__' as any, 'clear_all')
|
||||||
|
}
|
||||||
|
const userContent = newUserThreadContent(
|
||||||
|
activeThread.id,
|
||||||
|
message,
|
||||||
|
processedAttachments
|
||||||
|
)
|
||||||
|
if (troubleshooting) {
|
||||||
|
addMessage(userContent)
|
||||||
|
}
|
||||||
updateThreadTimestamp(activeThread.id)
|
updateThreadTimestamp(activeThread.id)
|
||||||
usePrompt.getState().setPrompt('')
|
usePrompt.getState().setPrompt('')
|
||||||
const selectedModel = useModelProvider.getState().selectedModel
|
const selectedModel = useModelProvider.getState().selectedModel
|
||||||
@ -296,7 +406,8 @@ export const useChat = () => {
|
|||||||
? renderInstructions(currentAssistant.instructions)
|
? renderInstructions(currentAssistant.instructions)
|
||||||
: undefined
|
: undefined
|
||||||
)
|
)
|
||||||
if (troubleshooting) builder.addUserMessage(message, attachments)
|
// Using addUserMessage to respect legacy code. Should be using the userContent above.
|
||||||
|
if (troubleshooting) builder.addUserMessage(userContent)
|
||||||
|
|
||||||
let isCompleted = false
|
let isCompleted = false
|
||||||
|
|
||||||
|
|||||||
@ -137,7 +137,9 @@ describe('CompletionMessagesBuilder', () => {
|
|||||||
it('should add user message to messages array', () => {
|
it('should add user message to messages array', () => {
|
||||||
const builder = new CompletionMessagesBuilder([])
|
const builder = new CompletionMessagesBuilder([])
|
||||||
|
|
||||||
builder.addUserMessage('Hello, how are you?')
|
builder.addUserMessage(
|
||||||
|
createMockThreadMessage('user', 'Hello, how are you?')
|
||||||
|
)
|
||||||
|
|
||||||
const result = builder.getMessages()
|
const result = builder.getMessages()
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
@ -150,8 +152,8 @@ describe('CompletionMessagesBuilder', () => {
|
|||||||
it('should not add consecutive user messages', () => {
|
it('should not add consecutive user messages', () => {
|
||||||
const builder = new CompletionMessagesBuilder([])
|
const builder = new CompletionMessagesBuilder([])
|
||||||
|
|
||||||
builder.addUserMessage('First message')
|
builder.addUserMessage(createMockThreadMessage('user', 'First message'))
|
||||||
builder.addUserMessage('Second message')
|
builder.addUserMessage(createMockThreadMessage('user', 'Second message'))
|
||||||
|
|
||||||
const result = builder.getMessages()
|
const result = builder.getMessages()
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
@ -161,7 +163,7 @@ describe('CompletionMessagesBuilder', () => {
|
|||||||
it('should handle empty user message', () => {
|
it('should handle empty user message', () => {
|
||||||
const builder = new CompletionMessagesBuilder([])
|
const builder = new CompletionMessagesBuilder([])
|
||||||
|
|
||||||
builder.addUserMessage('')
|
builder.addUserMessage(createMockThreadMessage('user', ''))
|
||||||
|
|
||||||
const result = builder.getMessages()
|
const result = builder.getMessages()
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
@ -338,7 +340,7 @@ describe('CompletionMessagesBuilder', () => {
|
|||||||
'You are helpful'
|
'You are helpful'
|
||||||
)
|
)
|
||||||
|
|
||||||
builder.addUserMessage('How are you?')
|
builder.addUserMessage(createMockThreadMessage('user', 'How are you?'))
|
||||||
builder.addAssistantMessage('I am well, thank you!')
|
builder.addAssistantMessage('I am well, thank you!')
|
||||||
builder.addToolMessage('Tool response', 'call_123')
|
builder.addToolMessage('Tool response', 'call_123')
|
||||||
|
|
||||||
@ -353,7 +355,7 @@ describe('CompletionMessagesBuilder', () => {
|
|||||||
it('should return the same array reference (not immutable)', () => {
|
it('should return the same array reference (not immutable)', () => {
|
||||||
const builder = new CompletionMessagesBuilder([])
|
const builder = new CompletionMessagesBuilder([])
|
||||||
|
|
||||||
builder.addUserMessage('Test message')
|
builder.addUserMessage(createMockThreadMessage('user', 'Test message'))
|
||||||
const result1 = builder.getMessages()
|
const result1 = builder.getMessages()
|
||||||
|
|
||||||
builder.addAssistantMessage('Response')
|
builder.addAssistantMessage('Response')
|
||||||
|
|||||||
@ -36,6 +36,8 @@ import { CompletionMessagesBuilder } from './messages'
|
|||||||
import { ChatCompletionMessageToolCall } from 'openai/resources'
|
import { ChatCompletionMessageToolCall } from 'openai/resources'
|
||||||
import { ExtensionManager } from './extension'
|
import { ExtensionManager } from './extension'
|
||||||
import { useAppState } from '@/hooks/useAppState'
|
import { useAppState } from '@/hooks/useAppState'
|
||||||
|
import { injectFilesIntoPrompt } from './fileMetadata'
|
||||||
|
import { Attachment } from '@/types/attachment'
|
||||||
|
|
||||||
export type ChatCompletionResponse =
|
export type ChatCompletionResponse =
|
||||||
| chatCompletion
|
| chatCompletion
|
||||||
@ -54,38 +56,48 @@ export type ChatCompletionResponse =
|
|||||||
export const newUserThreadContent = (
|
export const newUserThreadContent = (
|
||||||
threadId: string,
|
threadId: string,
|
||||||
content: string,
|
content: string,
|
||||||
attachments?: Array<{
|
attachments?: Attachment[]
|
||||||
name: string
|
|
||||||
type: string
|
|
||||||
size: number
|
|
||||||
base64: string
|
|
||||||
dataUrl: string
|
|
||||||
}>
|
|
||||||
): ThreadMessage => {
|
): ThreadMessage => {
|
||||||
|
// Separate images and documents
|
||||||
|
const images = attachments?.filter((a) => a.type === 'image') || []
|
||||||
|
const documents = attachments?.filter((a) => a.type === 'document') || []
|
||||||
|
|
||||||
|
// Inject document metadata into the text content (id, name, fileType only - no path)
|
||||||
|
const docMetadata = documents
|
||||||
|
.filter((doc) => doc.id) // Only include processed documents
|
||||||
|
.map((doc) => ({
|
||||||
|
id: doc.id!,
|
||||||
|
name: doc.name,
|
||||||
|
type: doc.fileType,
|
||||||
|
size: typeof doc.size === 'number' ? doc.size : undefined,
|
||||||
|
chunkCount: typeof doc.chunkCount === 'number' ? doc.chunkCount : undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const textWithFiles =
|
||||||
|
docMetadata.length > 0 ? injectFilesIntoPrompt(content, docMetadata) : content
|
||||||
|
|
||||||
const contentParts = [
|
const contentParts = [
|
||||||
{
|
{
|
||||||
type: ContentType.Text,
|
type: ContentType.Text,
|
||||||
text: {
|
text: {
|
||||||
value: content,
|
value: textWithFiles,
|
||||||
annotations: [],
|
annotations: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// Add attachments to content array
|
// Add image attachments to content array
|
||||||
if (attachments) {
|
images.forEach((img) => {
|
||||||
attachments.forEach((attachment) => {
|
if (img.base64 && img.mimeType) {
|
||||||
if (attachment.type.startsWith('image/')) {
|
contentParts.push({
|
||||||
contentParts.push({
|
type: ContentType.Image,
|
||||||
type: ContentType.Image,
|
image_url: {
|
||||||
image_url: {
|
url: `data:${img.mimeType};base64,${img.base64}`,
|
||||||
url: `data:${attachment.type};base64,${attachment.base64}`,
|
detail: 'auto',
|
||||||
detail: 'auto',
|
},
|
||||||
},
|
} as any)
|
||||||
} as any)
|
}
|
||||||
}
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
|||||||
96
web-app/src/lib/fileMetadata.ts
Normal file
96
web-app/src/lib/fileMetadata.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for embedding and extracting file metadata from user prompts
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FileMetadata {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type?: string
|
||||||
|
size?: number
|
||||||
|
chunkCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_METADATA_START = '[ATTACHED_FILES]'
|
||||||
|
const FILE_METADATA_END = '[/ATTACHED_FILES]'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject file metadata into user prompt at the end
|
||||||
|
* @param prompt - The user's message
|
||||||
|
* @param files - Array of file metadata
|
||||||
|
* @returns Prompt with embedded file metadata
|
||||||
|
*/
|
||||||
|
export function injectFilesIntoPrompt(
|
||||||
|
prompt: string,
|
||||||
|
files: FileMetadata[]
|
||||||
|
): string {
|
||||||
|
if (!files || files.length === 0) return prompt
|
||||||
|
|
||||||
|
const fileLines = files
|
||||||
|
.map((file) => {
|
||||||
|
const parts = [`file_id: ${file.id}`, `name: ${file.name}`]
|
||||||
|
if (file.type) parts.push(`type: ${file.type}`)
|
||||||
|
if (typeof file.size === 'number') parts.push(`size: ${file.size}`)
|
||||||
|
if (typeof file.chunkCount === 'number') parts.push(`chunks: ${file.chunkCount}`)
|
||||||
|
return `- ${parts.join(', ')}`
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const fileBlock = `\n\n${FILE_METADATA_START}\n${fileLines}\n${FILE_METADATA_END}`
|
||||||
|
|
||||||
|
return prompt + fileBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract file metadata from user prompt
|
||||||
|
* @param prompt - The prompt potentially containing file metadata
|
||||||
|
* @returns Object containing extracted files and clean prompt
|
||||||
|
*/
|
||||||
|
export function extractFilesFromPrompt(prompt: string): {
|
||||||
|
files: FileMetadata[]
|
||||||
|
cleanPrompt: string
|
||||||
|
} {
|
||||||
|
if (!prompt.includes(FILE_METADATA_START)) {
|
||||||
|
return { files: [], cleanPrompt: prompt }
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIndex = prompt.indexOf(FILE_METADATA_START)
|
||||||
|
const endIndex = prompt.indexOf(FILE_METADATA_END)
|
||||||
|
|
||||||
|
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
|
||||||
|
return { files: [], cleanPrompt: prompt }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the file metadata block
|
||||||
|
const fileBlock = prompt.substring(
|
||||||
|
startIndex + FILE_METADATA_START.length,
|
||||||
|
endIndex
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parse file metadata (flexible key:value parser)
|
||||||
|
const files: FileMetadata[] = []
|
||||||
|
const lines = fileBlock.trim().split('\n')
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.replace(/^\s*-\s*/, '').trim()
|
||||||
|
const parts = trimmed.split(',')
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
for (const part of parts) {
|
||||||
|
const [k, ...rest] = part.split(':')
|
||||||
|
if (!k || rest.length === 0) continue
|
||||||
|
map[k.trim()] = rest.join(':').trim()
|
||||||
|
}
|
||||||
|
const id = map['file_id']
|
||||||
|
const name = map['name']
|
||||||
|
if (!id || !name) continue
|
||||||
|
const type = map['type']
|
||||||
|
const size = map['size'] ? Number(map['size']) : undefined
|
||||||
|
const chunkCount = map['chunks'] ? Number(map['chunks']) : undefined
|
||||||
|
files.push({ id, name, ...(type && { type }), ...(typeof size === 'number' && !Number.isNaN(size) ? { size } : {}), ...(typeof chunkCount === 'number' && !Number.isNaN(chunkCount) ? { chunkCount } : {}) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract clean prompt (everything before [ATTACHED_FILES])
|
||||||
|
const cleanPrompt = prompt
|
||||||
|
.substring(0, startIndex)
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
return { files, cleanPrompt }
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import { ChatCompletionMessageParam } from 'token.js'
|
|||||||
import { ChatCompletionMessageToolCall } from 'openai/resources'
|
import { ChatCompletionMessageToolCall } from 'openai/resources'
|
||||||
import { ThreadMessage } from '@janhq/core'
|
import { ThreadMessage } from '@janhq/core'
|
||||||
import { removeReasoningContent } from '@/utils/reasoning'
|
import { removeReasoningContent } from '@/utils/reasoning'
|
||||||
|
// Attachments are now handled upstream in newUserThreadContent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @fileoverview Helper functions for creating chat completion request.
|
* @fileoverview Helper functions for creating chat completion request.
|
||||||
@ -21,106 +22,62 @@ export class CompletionMessagesBuilder {
|
|||||||
this.messages.push(
|
this.messages.push(
|
||||||
...messages
|
...messages
|
||||||
.filter((e) => !e.metadata?.error)
|
.filter((e) => !e.metadata?.error)
|
||||||
.map<ChatCompletionMessageParam>((msg) => {
|
.map<ChatCompletionMessageParam>((msg) => this.toCompletionParamFromThread(msg))
|
||||||
if (msg.role === 'assistant') {
|
|
||||||
return {
|
|
||||||
role: msg.role,
|
|
||||||
content: removeReasoningContent(
|
|
||||||
msg.content[0]?.text?.value || '.'
|
|
||||||
),
|
|
||||||
} as ChatCompletionMessageParam
|
|
||||||
} else {
|
|
||||||
// For user messages, handle multimodal content
|
|
||||||
if (msg.content.length > 1) {
|
|
||||||
// Multiple content parts (text + images + files)
|
|
||||||
|
|
||||||
const content = msg.content.map((contentPart) => {
|
|
||||||
if (contentPart.type === 'text') {
|
|
||||||
return {
|
|
||||||
type: 'text',
|
|
||||||
text: contentPart.text?.value || '',
|
|
||||||
}
|
|
||||||
} else if (contentPart.type === 'image_url') {
|
|
||||||
return {
|
|
||||||
type: 'image_url',
|
|
||||||
image_url: {
|
|
||||||
url: contentPart.image_url?.url || '',
|
|
||||||
detail: contentPart.image_url?.detail || 'auto',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return contentPart
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
role: msg.role,
|
|
||||||
content,
|
|
||||||
} as ChatCompletionMessageParam
|
|
||||||
} else {
|
|
||||||
// Single text content
|
|
||||||
return {
|
|
||||||
role: msg.role,
|
|
||||||
content: msg.content[0]?.text?.value || '.',
|
|
||||||
} as ChatCompletionMessageParam
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize a ThreadMessage into a ChatCompletionMessageParam for Token.js
|
||||||
|
private toCompletionParamFromThread(msg: ThreadMessage): ChatCompletionMessageParam {
|
||||||
|
if (msg.role === 'assistant') {
|
||||||
|
return {
|
||||||
|
role: 'assistant',
|
||||||
|
content: removeReasoningContent(msg.content?.[0]?.text?.value || '.'),
|
||||||
|
} as ChatCompletionMessageParam
|
||||||
|
}
|
||||||
|
|
||||||
|
// System messages are uncommon here; normalize to plain text
|
||||||
|
if (msg.role === 'system') {
|
||||||
|
return {
|
||||||
|
role: 'system',
|
||||||
|
content: msg.content?.[0]?.text?.value || '.',
|
||||||
|
} as ChatCompletionMessageParam
|
||||||
|
}
|
||||||
|
|
||||||
|
// User messages: handle multimodal content
|
||||||
|
if (Array.isArray(msg.content) && msg.content.length > 1) {
|
||||||
|
const content = msg.content.map((part: any) => {
|
||||||
|
if (part.type === 'text') {
|
||||||
|
return { type: 'text', text: part.text?.value ?? '' }
|
||||||
|
}
|
||||||
|
if (part.type === 'image_url') {
|
||||||
|
return {
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: part.image_url?.url || '', detail: part.image_url?.detail || 'auto' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return part
|
||||||
|
})
|
||||||
|
return { role: 'user', content } as any
|
||||||
|
}
|
||||||
|
// Single text part
|
||||||
|
const text = msg?.content?.[0]?.text?.value ?? '.'
|
||||||
|
return { role: 'user', content: text }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a user message to the messages array.
|
* Add a user message to the messages array from a parsed ThreadMessage.
|
||||||
* @param content - The content of the user message.
|
* Upstream code should construct the message via newUserThreadContent
|
||||||
* @param attachments - Optional attachments for the message.
|
* and pass it here to avoid duplicated logic.
|
||||||
*/
|
*/
|
||||||
addUserMessage(
|
addUserMessage(message: ThreadMessage) {
|
||||||
content: string,
|
if (message.role !== 'user') {
|
||||||
attachments?: Array<{
|
throw new Error('addUserMessage expects a user ThreadMessage')
|
||||||
name: string
|
}
|
||||||
type: string
|
|
||||||
size: number
|
|
||||||
base64: string
|
|
||||||
dataUrl: string
|
|
||||||
}>
|
|
||||||
) {
|
|
||||||
// Ensure no consecutive user messages
|
// Ensure no consecutive user messages
|
||||||
if (this.messages[this.messages.length - 1]?.role === 'user') {
|
if (this.messages[this.messages.length - 1]?.role === 'user') {
|
||||||
this.messages.pop()
|
this.messages.pop()
|
||||||
}
|
}
|
||||||
|
this.messages.push(this.toCompletionParamFromThread(message))
|
||||||
// Handle multimodal content with attachments
|
|
||||||
if (attachments && attachments.length > 0) {
|
|
||||||
const messageContent: any[] = [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: content,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// Add attachments (images and PDFs)
|
|
||||||
attachments.forEach((attachment) => {
|
|
||||||
if (attachment.type.startsWith('image/')) {
|
|
||||||
messageContent.push({
|
|
||||||
type: 'image_url',
|
|
||||||
image_url: {
|
|
||||||
url: `data:${attachment.type};base64,${attachment.base64}`,
|
|
||||||
detail: 'auto',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
this.messages.push({
|
|
||||||
role: 'user',
|
|
||||||
content: messageContent,
|
|
||||||
} as any)
|
|
||||||
} else {
|
|
||||||
// Text-only message
|
|
||||||
this.messages.push({
|
|
||||||
role: 'user',
|
|
||||||
content: content,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -29,6 +29,8 @@ import { DefaultDeepLinkService } from './deeplink/default'
|
|||||||
import { DefaultProjectsService } from './projects/default'
|
import { DefaultProjectsService } from './projects/default'
|
||||||
import { DefaultRAGService } from './rag/default'
|
import { DefaultRAGService } from './rag/default'
|
||||||
import type { RAGService } from './rag/types'
|
import type { RAGService } from './rag/types'
|
||||||
|
import { DefaultUploadsService } from './uploads/default'
|
||||||
|
import type { UploadsService } from './uploads/types'
|
||||||
|
|
||||||
// Import service types
|
// Import service types
|
||||||
import type { ThemeService } from './theme/types'
|
import type { ThemeService } from './theme/types'
|
||||||
@ -73,6 +75,7 @@ export interface ServiceHub {
|
|||||||
deeplink(): DeepLinkService
|
deeplink(): DeepLinkService
|
||||||
projects(): ProjectsService
|
projects(): ProjectsService
|
||||||
rag(): RAGService
|
rag(): RAGService
|
||||||
|
uploads(): UploadsService
|
||||||
}
|
}
|
||||||
|
|
||||||
class PlatformServiceHub implements ServiceHub {
|
class PlatformServiceHub implements ServiceHub {
|
||||||
@ -96,6 +99,7 @@ class PlatformServiceHub implements ServiceHub {
|
|||||||
private deepLinkService: DeepLinkService = new DefaultDeepLinkService()
|
private deepLinkService: DeepLinkService = new DefaultDeepLinkService()
|
||||||
private projectsService: ProjectsService = new DefaultProjectsService()
|
private projectsService: ProjectsService = new DefaultProjectsService()
|
||||||
private ragService: RAGService = new DefaultRAGService()
|
private ragService: RAGService = new DefaultRAGService()
|
||||||
|
private uploadsService: UploadsService = new DefaultUploadsService()
|
||||||
private initialized = false
|
private initialized = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -352,6 +356,11 @@ class PlatformServiceHub implements ServiceHub {
|
|||||||
this.ensureInitialized()
|
this.ensureInitialized()
|
||||||
return this.ragService
|
return this.ragService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uploads(): UploadsService {
|
||||||
|
this.ensureInitialized()
|
||||||
|
return this.uploadsService
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initializeServiceHub(): Promise<ServiceHub> {
|
export async function initializeServiceHub(): Promise<ServiceHub> {
|
||||||
|
|||||||
32
web-app/src/services/uploads/default.ts
Normal file
32
web-app/src/services/uploads/default.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import type { UploadsService, UploadResult } from './types'
|
||||||
|
import type { Attachment, } from '@/types/attachment'
|
||||||
|
import { ulid } from 'ulidx'
|
||||||
|
import { ExtensionManager } from '@/lib/extension'
|
||||||
|
import { ExtensionTypeEnum, type RAGExtension, type IngestAttachmentsResult } from '@janhq/core'
|
||||||
|
|
||||||
|
export class DefaultUploadsService implements UploadsService {
|
||||||
|
async ingestImage(_threadId: string, attachment: Attachment): Promise<UploadResult> {
|
||||||
|
if (attachment.type !== 'image') throw new Error('ingestImage: attachment is not image')
|
||||||
|
// Placeholder upload flow; swap for real API call when backend is ready
|
||||||
|
await new Promise((r) => setTimeout(r, 100))
|
||||||
|
return { id: ulid() }
|
||||||
|
}
|
||||||
|
|
||||||
|
async ingestFileAttachment(threadId: string, attachment: Attachment): Promise<UploadResult> {
|
||||||
|
if (attachment.type !== 'document') throw new Error('ingestFileAttachment: attachment is not document')
|
||||||
|
const ext = ExtensionManager.getInstance().get<RAGExtension>(ExtensionTypeEnum.RAG)
|
||||||
|
if (!ext?.ingestAttachments) throw new Error('RAG extension not available')
|
||||||
|
const res: IngestAttachmentsResult = await ext.ingestAttachments(threadId, [
|
||||||
|
{ path: attachment.path!, name: attachment.name, type: attachment.fileType, size: attachment.size },
|
||||||
|
])
|
||||||
|
const files = res.files
|
||||||
|
if (Array.isArray(files) && files[0]?.id) {
|
||||||
|
return {
|
||||||
|
id: files[0].id,
|
||||||
|
size: typeof files[0].size === 'number' ? Number(files[0].size) : undefined,
|
||||||
|
chunkCount: typeof files[0].chunk_count === 'number' ? Number(files[0].chunk_count) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Failed to resolve ingested attachment id')
|
||||||
|
}
|
||||||
|
}
|
||||||
16
web-app/src/services/uploads/types.ts
Normal file
16
web-app/src/services/uploads/types.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { Attachment } from '@/types/attachment'
|
||||||
|
|
||||||
|
export type UploadResult = {
|
||||||
|
id: string
|
||||||
|
url?: string
|
||||||
|
size?: number
|
||||||
|
chunkCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadsService {
|
||||||
|
// Ingest an image attachment (placeholder upload)
|
||||||
|
ingestImage(threadId: string, attachment: Attachment): Promise<UploadResult>
|
||||||
|
|
||||||
|
// Ingest a document attachment in the context of a thread
|
||||||
|
ingestFileAttachment(threadId: string, attachment: Attachment): Promise<UploadResult>
|
||||||
|
}
|
||||||
57
web-app/src/types/attachment.ts
Normal file
57
web-app/src/types/attachment.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Unified attachment type for both images and documents
|
||||||
|
*/
|
||||||
|
export type Attachment = {
|
||||||
|
name: string
|
||||||
|
type: 'image' | 'document'
|
||||||
|
|
||||||
|
// Common fields
|
||||||
|
size?: number
|
||||||
|
chunkCount?: number
|
||||||
|
processing?: boolean
|
||||||
|
processed?: boolean
|
||||||
|
error?: string
|
||||||
|
|
||||||
|
// For images (before upload)
|
||||||
|
base64?: string
|
||||||
|
dataUrl?: string
|
||||||
|
mimeType?: string
|
||||||
|
|
||||||
|
// For documents (local files)
|
||||||
|
path?: string
|
||||||
|
fileType?: string // e.g., 'pdf', 'docx'
|
||||||
|
|
||||||
|
// After processing (images uploaded, documents ingested)
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create image attachment
|
||||||
|
*/
|
||||||
|
export function createImageAttachment(data: {
|
||||||
|
name: string
|
||||||
|
base64: string
|
||||||
|
dataUrl: string
|
||||||
|
mimeType: string
|
||||||
|
size: number
|
||||||
|
}): Attachment {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
type: 'image',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create document attachment
|
||||||
|
*/
|
||||||
|
export function createDocumentAttachment(data: {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
fileType?: string
|
||||||
|
size?: number
|
||||||
|
}): Attachment {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
type: 'document',
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user