diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 92c8da31..b3a13d14 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -273,7 +273,7 @@ jobs: with: files: ${{ steps.pkg.outputs.files }} generate_release_notes: true - draft: false + draft: true prerelease: ${{ contains(github.ref, '-rc') || contains(github.ref, '-beta') || contains(github.ref, '-alpha') }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 488f52f0..1c0a9223 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -474,3 +474,71 @@ jobs: draft: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + + + contributors: + name: Append contributors to release notes + if: startsWith(github.ref, 'refs/tags/v') + needs: [ release ] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout (full history for git log) + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Build contributors section and append to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + TAG="${{ github.ref_name }}" + + # Find the previous tag (the one before this one) + PREV_TAG=$(git tag --sort=-version:refname | grep -v "^${TAG}$" | head -n1 || true) + + if [ -n "$PREV_TAG" ]; then + RANGE="${PREV_TAG}..${TAG}" + echo "Contributors from $RANGE" + else + RANGE="${TAG}" + echo "No previous tag found — listing all contributors up to $TAG" + fi + + # Collect unique authors: "Login (Name)" sorted, bots excluded + mapfile -t AUTHORS < <( + git log "$RANGE" --format='%aN|%aE' \ + | sort -u \ + | grep -viE '\[bot\]|noreply\.github\.com' \ + | awk -F'|' '{print $1}' \ + | sort -u + ) + + if [ ${#AUTHORS[@]} -eq 0 ]; then + echo "No human contributors found — skipping append." + exit 0 + fi + + echo "Found ${#AUTHORS[@]} contributor(s): ${AUTHORS[*]}" + + # Build markdown section + SECTION=$'\n\n## Contributors\n\nThank you to everyone who contributed to this release! 🎉\n\n' + for AUTHOR in "${AUTHORS[@]}"; do + SECTION+="- ${AUTHOR}"$'\n' + done + + # Fetch current release body + CURRENT_BODY=$(gh release view "$TAG" --json body --jq '.body' 2>/dev/null || echo "") + + # Remove any previously appended Contributors section (idempotent re-runs) + STRIPPED=$(echo "$CURRENT_BODY" | sed '/^## Contributors/,$ d') + + NEW_BODY="${STRIPPED}${SECTION}" + + gh release edit "$TAG" --notes "$NEW_BODY" + echo "✅ Contributors section appended to release $TAG" + diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatInputField.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatInputField.kt index 7254655c..3ddb8be3 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatInputField.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatInputField.kt @@ -32,6 +32,8 @@ import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox @@ -75,6 +77,7 @@ import androidx.compose.ui.window.Popup import io.askimo.core.chat.dto.ChatMessageDTO import io.askimo.core.chat.dto.FileAttachmentDTO import io.askimo.core.chat.service.ChatSessionService +import io.askimo.core.chat.util.FileContentExtractor import io.askimo.core.config.AppConfig import io.askimo.core.event.EventBus import io.askimo.core.event.error.AppErrorEvent @@ -1104,53 +1107,159 @@ private fun fileAttachmentItem( attachment: FileAttachmentDTO, onRemove: () -> Unit, ) { + var expanded by remember { mutableStateOf(false) } + var previewContent by remember { mutableStateOf(null) } + var isLoadingPreview by remember { mutableStateOf(false) } + + // Determine if the file is previewable as text — delegate to FileContentExtractor + val isTextFile = FileContentExtractor.isTextFile(attachment.fileName) + + LaunchedEffect(expanded) { + if (expanded && isTextFile && previewContent == null && !isLoadingPreview) { + isLoadingPreview = true + previewContent = withContext(Dispatchers.IO) { + try { + val file = attachment.filePath?.let { java.io.File(it) } + if (file != null && file.exists() && file.length() < 512 * 1024) { + // Read up to 200 lines for preview + file.bufferedReader().use { reader -> + val lines = reader.readLines() + val preview = lines.take(200).joinToString("\n") + if (lines.size > 200) { + "$preview\n… (${LocalizationManager.getString("chat.attachment.preview.more.lines", lines.size - 200)})" + } else { + preview + } + } + } else if (file != null && !file.exists()) { + LocalizationManager.getString("chat.attachment.preview.file.not.found") + } else { + LocalizationManager.getString("chat.attachment.preview.too.large") + } + } catch (e: Exception) { + LocalizationManager.getString("chat.attachment.preview.cannot.read", e.message ?: "") + } + } + isLoadingPreview = false + } + } + Card( modifier = Modifier.fillMaxWidth(), colors = AppComponents.surfaceVariantCardColors(), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { + Column(modifier = Modifier.fillMaxWidth()) { + // Header row Row( + modifier = Modifier + .fillMaxWidth() + .then( + if (isTextFile) { + Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { expanded = !expanded }, + ).pointerHoverIcon(PointerIcon.Hand) + } else { + Modifier + }, + ) + .padding(8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.SpaceBetween, ) { - Icon( - imageVector = Icons.Default.AttachFile, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Column { - Text( - text = attachment.fileName, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = formatFileSize(attachment.size), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f), + ) { + Icon( + imageVector = Icons.Default.AttachFile, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurface, ) + Column { + Text( + text = attachment.fileName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = formatFileSize(attachment.size), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isTextFile) { + Icon( + imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (expanded) "Collapse preview" else "Expand preview", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton( + onClick = onRemove, + modifier = Modifier + .size(24.dp) + .pointerHoverIcon(PointerIcon.Hand), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource("chat.attachment.remove"), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } } } - IconButton( - onClick = onRemove, - modifier = Modifier - .size(24.dp) - .pointerHoverIcon(PointerIcon.Hand), - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource("chat.attachment.remove"), - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) + + // Inline preview panel + if (expanded && isTextFile) { + HorizontalDivider() + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 240.dp) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + when { + isLoadingPreview -> { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp) + Text( + text = stringResource("chat.attachment.preview.loading"), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + previewContent != null -> { + val scrollState = rememberScrollState() + androidx.compose.foundation.text.selection.SelectionContainer { + Text( + text = previewContent!!, + style = MaterialTheme.typography.labelSmall.copy( + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.verticalScroll(scrollState), + ) + } + } + } + } } } } diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatView.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatView.kt index aa600569..653294b4 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatView.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatView.kt @@ -104,6 +104,7 @@ import kotlinx.coroutines.withContext import org.koin.core.context.GlobalContext import java.io.File import java.time.LocalDateTime +import java.util.UUID.randomUUID private val log = currentFileLogger() @@ -149,10 +150,16 @@ fun chatView( // Internal state management for ChatView val scope = rememberCoroutineScope() - var inputText by remember(initialInputText) { mutableStateOf(initialInputText) } - var attachments by remember(initialAttachments) { mutableStateOf(initialAttachments) } - var editingMessage by remember(initialEditingMessage) { mutableStateOf(initialEditingMessage) } - var editingAIMessage by remember { mutableStateOf(null) } + var inputText by remember(sessionId, initialInputText) { mutableStateOf(initialInputText) } + var attachments by remember(sessionId, initialAttachments) { mutableStateOf(initialAttachments) } + var editingMessage by remember(sessionId, initialEditingMessage) { mutableStateOf(initialEditingMessage) } + var editingAIMessage by remember(sessionId) { mutableStateOf(null) } + + // When there is no project (e.g. user clicked "New Chat"), drop any pending + // attachments that were added from the project side panel. + LaunchedEffect(project) { + if (project == null) attachments = emptyList() + } // Notify parent of state changes LaunchedEffect(inputText, attachments, editingMessage) { @@ -1086,6 +1093,28 @@ fun chatView( ragIndexingPercentage = ragIndexingPercentage, isExpanded = sidePanelExpanded, onExpandedChange = { sidePanelExpanded = it }, + onAddToChat = { filePaths -> + val newAttachments = filePaths.map { path -> + val file = File(path) + FileAttachmentDTO( + id = randomUUID().toString(), + messageId = "", + sessionId = sessionId ?: "", + fileName = file.name, + mimeType = file.extension, + size = file.length(), + createdAt = LocalDateTime.now(), + content = null, + filePath = file.absolutePath, + ) + } + // Merge, avoiding duplicates by path + val existingPaths = attachments.mapNotNull { it.filePath }.toSet() + val toAdd = newAttachments.filter { it.filePath !in existingPaths } + if (toAdd.isNotEmpty()) { + attachments = attachments + toAdd + } + }, modifier = Modifier.fillMaxHeight(), ) } diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/FileViewerPane.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/FileViewerPane.kt index 36975f07..c25aadc9 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/FileViewerPane.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/FileViewerPane.kt @@ -23,10 +23,10 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.InsertDriveFile +import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.FolderOpen -import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -161,7 +161,7 @@ fun fileViewerPane( modifier = Modifier.size(28.dp).pointerHoverIcon(PointerIcon.Hand), ) { Icon( - imageVector = Icons.Default.OpenInNew, + imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = stringResource("file.viewer.open.external"), tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(15.dp), @@ -252,7 +252,7 @@ fun fileViewerPane( Icon( imageVector = Icons.Default.Warning, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, + tint = MaterialTheme.colorScheme.tertiary, modifier = Modifier.size(32.dp), ) }, @@ -322,10 +322,15 @@ private fun viewerPlaceholder( Icon( imageVector = Icons.Default.FolderOpen, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(15.dp), ) Spacer(Modifier.size(6.dp)) - Text(text = actionLabel, style = MaterialTheme.typography.bodySmall) + Text( + text = actionLabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) } } } diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ProjectSidePanel.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ProjectSidePanel.kt index 887c403f..49176daf 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ProjectSidePanel.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ProjectSidePanel.kt @@ -98,6 +98,7 @@ fun projectSidePanel( ragIndexingPercentage: Int?, isExpanded: Boolean, onExpandedChange: (Boolean) -> Unit, + onAddToChat: ((List) -> Unit)? = null, modifier: Modifier = Modifier, ) { var selectedTab by remember { mutableStateOf(PanelTab.RAG_SOURCES) } @@ -304,6 +305,7 @@ fun projectSidePanel( ) } }, + onAddToChat = onAddToChat, ) } @@ -437,6 +439,7 @@ private fun ragSourcesTabContent( ragIndexingPercentage: Int?, onAddMaterial: () -> Unit, onRemove: (KnowledgeSourceConfig) -> Unit, + onAddToChat: ((List) -> Unit)? = null, ) { var selectedNode by remember { mutableStateOf(null) } var viewerHeightRatio by remember { @@ -536,6 +539,7 @@ private fun ragSourcesTabContent( } onRemove(source) }, + onAddToChat = onAddToChat, ) } } diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/RagSourcesTree.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/RagSourcesTree.kt index 0408fd06..e4b6a6d6 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/RagSourcesTree.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/RagSourcesTree.kt @@ -25,20 +25,28 @@ import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.AddCircleOutline +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateSetOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -47,6 +55,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp @@ -67,11 +76,16 @@ import java.net.URI * Tree view component for displaying RAG knowledge sources. * Shows files and folders with expandable/collapsible functionality. * + * When [onAddToChat] is provided, each file/folder row gains a "+" quick-add button, + * a context-menu "Add to chat" option, and a sticky bottom bar for multi-file selection. + * * @param sources Knowledge source configs to display. * @param modifier Optional modifier. * @param selectedNode Currently selected node (hoisted to allow viewer integration). * @param onNodeSelected Called when a node is selected; passes `null` to deselect. * @param onRemove Called when the user removes a knowledge source. + * @param onAddToChat Called with a list of file paths to attach to the current chat message. + * Pass `null` to disable the feature. */ @OptIn(ExperimentalFoundationApi::class) @Composable @@ -81,7 +95,11 @@ fun ragSourcesTree( selectedNode: TreeNode? = null, onNodeSelected: (TreeNode?) -> Unit = {}, onRemove: (KnowledgeSourceConfig) -> Unit = {}, + onAddToChat: ((List) -> Unit)? = null, ) { + // Paths currently checked for bulk "Add to chat" + val chatSelection = remember { mutableStateSetOf() } + // Convert sources to tree nodes val treeNodes = remember(sources) { sources.map { source -> @@ -112,39 +130,107 @@ fun ragSourcesTree( val listState = rememberLazyListState() - Box(modifier = modifier) { - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 22.dp), // left padding matches header; right leaves room for scrollbar - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - items(treeNodes) { node -> - treeNodeItem( - node = node, - level = 0, - selectedNode = selectedNode, - onNodeSelected = onNodeSelected, - onRemove = onRemove, - ) + Column(modifier = modifier) { + Box(modifier = Modifier.weight(1f)) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 22.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(treeNodes) { node -> + treeNodeItem( + node = node, + level = 0, + selectedNode = selectedNode, + onNodeSelected = onNodeSelected, + onRemove = onRemove, + chatSelection = chatSelection, + onAddToChat = onAddToChat, + ) + } } + + VerticalScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight(), + adapter = rememberScrollbarAdapter(listState), + style = ScrollbarStyle( + minimalHeight = 16.dp, + thickness = 6.dp, + shape = MaterialTheme.shapes.small, + hoverDurationMillis = 300, + unhoverColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + hoverColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ), + ) } - VerticalScrollbar( - modifier = Modifier - .align(Alignment.CenterEnd) - .fillMaxHeight(), - adapter = rememberScrollbarAdapter(listState), - style = ScrollbarStyle( - minimalHeight = 16.dp, - thickness = 6.dp, - shape = MaterialTheme.shapes.small, - hoverDurationMillis = 300, - unhoverColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - hoverColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - ), - ) + // Sticky bottom action bar — only shown when files are selected for chat + if (onAddToChat != null && chatSelection.isNotEmpty()) { + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.4f)) + .padding(horizontal = 12.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.AttachFile, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(16.dp), + ) + Text( + text = stringResource("rag.tree.chat.selected", chatSelection.size), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + onClick = { chatSelection.clear() }, + modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), + ) { + Text( + text = stringResource("rag.tree.chat.clear"), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + Button( + onClick = { + onAddToChat(chatSelection.toList()) + chatSelection.clear() + }, + modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), + ) { + Icon( + imageVector = Icons.Default.AttachFile, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + Text( + text = stringResource("rag.tree.chat.add"), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(start = 4.dp), + ) + } + } + } + } } } @@ -192,11 +278,13 @@ private fun treeNodeItem( selectedNode: TreeNode?, onNodeSelected: (TreeNode) -> Unit, onRemove: (KnowledgeSourceConfig) -> Unit, + chatSelection: androidx.compose.runtime.snapshots.SnapshotStateSet = remember { mutableStateSetOf() }, + onAddToChat: ((List) -> Unit)? = null, modifier: Modifier = Modifier, ) { when (node) { - is FolderTreeNode -> folderNodeItem(node, level, selectedNode, onNodeSelected, onRemove, modifier) - is FileTreeNode -> fileNodeItem(node, level, selectedNode, onNodeSelected, onRemove, modifier) + is FolderTreeNode -> folderNodeItem(node, level, selectedNode, onNodeSelected, onRemove, chatSelection, onAddToChat, modifier) + is FileTreeNode -> fileNodeItem(node, level, selectedNode, onNodeSelected, onRemove, chatSelection, onAddToChat, modifier) is UrlTreeNode -> urlNodeItem(node, level, selectedNode, onNodeSelected, onRemove, modifier) } } @@ -212,6 +300,8 @@ private fun folderNodeItem( selectedNode: TreeNode?, onNodeSelected: (TreeNode) -> Unit, onRemove: (KnowledgeSourceConfig) -> Unit, + chatSelection: androidx.compose.runtime.snapshots.SnapshotStateSet = remember { mutableStateSetOf() }, + onAddToChat: ((List) -> Unit)? = null, modifier: Modifier = Modifier, ) { var isExpanded by remember { mutableStateOf(false) } @@ -235,7 +325,9 @@ private fun folderNodeItem( Column(modifier = modifier) { // Folder row themedTooltip(text = node.fullPath) { - Box { + Box( + modifier = Modifier.fillMaxWidth(), + ) { Row( modifier = Modifier .fillMaxWidth() @@ -274,11 +366,7 @@ private fun folderNodeItem( // Folder icon Icon( - imageVector = if (isExpanded) { - Icons.Default.FolderOpen - } else { - Icons.Default.Folder - }, + imageVector = if (isExpanded) Icons.Default.FolderOpen else Icons.Default.Folder, contentDescription = null, tint = AppComponents.secondaryIconColor(), modifier = Modifier.size(18.dp), @@ -293,9 +381,10 @@ private fun folderNodeItem( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) + // No "Add to chat" button for folders — only individual files can be attached } - // Context menu + // Context menu — no "Add to chat" option for folders DropdownMenu( expanded = showContextMenu, onDismissRequest = { showContextMenu = false }, @@ -345,6 +434,8 @@ private fun folderNodeItem( selectedNode = selectedNode, onNodeSelected = onNodeSelected, onRemove = onRemove, + chatSelection = chatSelection, + onAddToChat = onAddToChat, ) } } @@ -363,14 +454,17 @@ private fun fileNodeItem( selectedNode: TreeNode?, onNodeSelected: (TreeNode) -> Unit, onRemove: (KnowledgeSourceConfig) -> Unit, + chatSelection: androidx.compose.runtime.snapshots.SnapshotStateSet = remember { mutableStateSetOf() }, + onAddToChat: ((List) -> Unit)? = null, modifier: Modifier = Modifier, ) { var showContextMenu by remember { mutableStateOf(false) } val isSelected = selectedNode == node - val backgroundColor = if (isSelected) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) - } else { - Color.Transparent + val isInChatSelection = node.path in chatSelection + val backgroundColor = when { + isInChatSelection -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.35f) + isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + else -> Color.Transparent } themedTooltip(text = node.fullPath) { @@ -412,6 +506,35 @@ private fun fileNodeItem( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) + + // "Add to chat" quick button + if (onAddToChat != null) { + if (isInChatSelection) { + IconButton( + onClick = { chatSelection.remove(node.path) }, + modifier = Modifier.size(20.dp).pointerHoverIcon(PointerIcon.Hand), + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = stringResource("rag.tree.chat.deselect"), + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(16.dp), + ) + } + } else { + IconButton( + onClick = { chatSelection.add(node.path) }, + modifier = Modifier.size(20.dp).pointerHoverIcon(PointerIcon.Hand), + ) { + Icon( + imageVector = Icons.Default.AddCircleOutline, + contentDescription = stringResource("rag.tree.chat.select"), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.size(16.dp), + ) + } + } + } } // Context menu @@ -420,6 +543,32 @@ private fun fileNodeItem( onDismissRequest = { showContextMenu = false }, offset = DpOffset(x = 0.dp, y = 0.dp), ) { + if (onAddToChat != null) { + if (isInChatSelection) { + DropdownMenuItem( + text = { Text(stringResource("rag.tree.chat.deselect")) }, + onClick = { + chatSelection.remove(node.path) + showContextMenu = false + }, + leadingIcon = { + Icon(Icons.Default.CheckCircle, contentDescription = null) + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource("rag.tree.chat.select")) }, + onClick = { + chatSelection.add(node.path) + showContextMenu = false + }, + leadingIcon = { + Icon(Icons.Default.AddCircleOutline, contentDescription = null) + }, + ) + } + HorizontalDivider() + } DropdownMenuItem( text = { Text(stringResource("rag.tree.file.preview")) }, onClick = { diff --git a/desktop-shared/src/main/resources/i18n/messages.properties b/desktop-shared/src/main/resources/i18n/messages.properties index 107a22c7..a1127e41 100644 --- a/desktop-shared/src/main/resources/i18n/messages.properties +++ b/desktop-shared/src/main/resources/i18n/messages.properties @@ -486,6 +486,11 @@ chat.tools.button.disabled=Available Tools ({0} disabled) chat.input.placeholder=Type your message... (Enter to send, Shift+Enter for new line) chat.select.file=Select File(s) chat.attachment.remove=Remove attachment +chat.attachment.preview.loading=Loading preview\u2026 +chat.attachment.preview.file.not.found=(File not found) +chat.attachment.preview.too.large=(File too large to preview) +chat.attachment.preview.cannot.read=(Cannot read file: {0}) +chat.attachment.preview.more.lines={0} more lines # Sessions View sessions.title=Chat Sessions @@ -872,7 +877,6 @@ knowledge.source.type.url=URL (web content) # MCP Server Management mcp.servers.title=MCP Server Templates mcp.servers.description=Manage Model Context Protocol server templates that can be used across projects -mcp.servers.add=Add Server Template mcp.servers.edit=Edit Template mcp.servers.delete=Delete Template mcp.servers.delete.confirm.title=Delete MCP Server Template? @@ -1096,6 +1100,11 @@ rag.tree.url.open=Open in Browser rag.tree.url.copy=Copy URL rag.tree.remove=Remove Reference Material rag.tree.file.preview=Preview File +rag.tree.chat.select=Add to Chat +rag.tree.chat.deselect=Remove from Chat +rag.tree.chat.selected={0} file(s) selected +rag.tree.chat.clear=Clear +rag.tree.chat.add=Add to Chat # File Viewer Pane file.viewer.loading=Loading... diff --git a/desktop-shared/src/main/resources/i18n/messages_de.properties b/desktop-shared/src/main/resources/i18n/messages_de.properties index 297a4d81..3e45268c 100644 --- a/desktop-shared/src/main/resources/i18n/messages_de.properties +++ b/desktop-shared/src/main/resources/i18n/messages_de.properties @@ -488,6 +488,11 @@ chat.tools.button.disabled=Verfügbare Tools ({0} deaktiviert) chat.input.placeholder=Nachricht eingeben... (Enter zum Senden, Shift+Enter für neue Zeile) chat.select.file=Datei(en) auswählen chat.attachment.remove=Anhang entfernen +chat.attachment.preview.loading=Vorschau wird geladen… +chat.attachment.preview.file.not.found=(Datei nicht gefunden) +chat.attachment.preview.too.large=(Datei zu groß für eine Vorschau) +chat.attachment.preview.cannot.read=(Datei kann nicht gelesen werden: {0}) +chat.attachment.preview.more.lines={0} weitere Zeilen # Sessions View sessions.title=Chatsitzungen @@ -878,7 +883,6 @@ knowledge.source.type.url=URL (Webinhalte) # MCP Server Management mcp.servers.title=MCP-Servervorlagen mcp.servers.description=Verwalten von Model-Context-Protocol-Servervorlagen, die projektübergreifend verwendet werden können -mcp.servers.add=Servervorlage hinzufügen mcp.servers.edit=Vorlage bearbeiten mcp.servers.delete=Vorlage löschen mcp.servers.delete.confirm.title=MCP-Servervorlage löschen? @@ -1102,6 +1106,23 @@ rag.tree.file.copy.path=Pfad kopieren rag.tree.url.open=Im Browser öffnen rag.tree.url.copy=URL kopieren rag.tree.remove=Referenzmaterial entfernen +rag.tree.file.preview=Dateivorschau +rag.tree.chat.select=Zum Chat hinzufügen +rag.tree.chat.deselect=Aus dem Chat entfernen +rag.tree.chat.selected={0} Datei(en) ausgewählt +rag.tree.chat.clear=Leeren +rag.tree.chat.add=Zum Chat hinzufügen + +# File Viewer Pane +file.viewer.loading=Wird geladen... +file.viewer.lines={0} Zeilen +file.viewer.copy=Inhalt kopieren +file.viewer.close=Viewer schließen +file.viewer.open.external=Extern öffnen +file.viewer.binary=Binärdatei — Vorschau als Text nicht möglich. +file.viewer.too.large=Datei ist zu groß für eine Vorschau ({0} KB). Das Maximum liegt bei 512 KB. +file.viewer.error=Datei konnte nicht gelesen werden: {0} +file.viewer.select.prompt=Wählen Sie eine Datei aus dem Baum oben aus, um ihren Inhalt als Vorschau anzuzeigen # Project Side Panel panel.tab.rag.sources=RAG-Quellen diff --git a/desktop-shared/src/main/resources/i18n/messages_es.properties b/desktop-shared/src/main/resources/i18n/messages_es.properties index bbbdcd29..3360b146 100644 --- a/desktop-shared/src/main/resources/i18n/messages_es.properties +++ b/desktop-shared/src/main/resources/i18n/messages_es.properties @@ -487,6 +487,11 @@ chat.tools.button.disabled=Herramientas disponibles ({0} deshabilitadas) chat.input.placeholder=Escribe tu mensaje... (Enter para enviar, Shift+Enter para nueva línea) chat.select.file=Seleccionar archivo(s) chat.attachment.remove=Eliminar archivo adjunto +chat.attachment.preview.loading=Cargando vista previa… +chat.attachment.preview.file.not.found=(Archivo no encontrado) +chat.attachment.preview.too.large=(Archivo demasiado grande para la vista previa) +chat.attachment.preview.cannot.read=(No se puede leer el archivo: {0}) +chat.attachment.preview.more.lines={0} líneas más # Sessions View sessions.title=Sesiones de chat @@ -875,7 +880,6 @@ knowledge.source.type.url=URL (contenido web) # MCP Server Management mcp.servers.title=Plantillas de servidores MCP mcp.servers.description=Gestiona plantillas de servidores de Model Context Protocol que pueden reutilizarse en varios proyectos -mcp.servers.add=Agregar plantilla de servidor mcp.servers.edit=Editar plantilla mcp.servers.delete=Eliminar plantilla mcp.servers.delete.confirm.title=¿Eliminar plantilla de servidor MCP? @@ -1100,6 +1104,23 @@ rag.tree.file.copy.path=Copiar ruta rag.tree.url.open=Abrir en el navegador rag.tree.url.copy=Copiar URL rag.tree.remove=Eliminar material de referencia +rag.tree.file.preview=Vista previa del archivo +rag.tree.chat.select=Añadir al chat +rag.tree.chat.deselect=Eliminar del chat +rag.tree.chat.selected={0} archivo(s) seleccionado(s) +rag.tree.chat.clear=Limpiar +rag.tree.chat.add=Añadir al chat + +# File Viewer Pane +file.viewer.loading=Cargando... +file.viewer.lines={0} líneas +file.viewer.copy=Copiar contenido +file.viewer.close=Cerrar visor +file.viewer.open.external=Abrir externamente +file.viewer.binary=Archivo binario — no se puede previsualizar como texto. +file.viewer.too.large=El archivo es demasiado grande para previsualizarlo ({0} KB). El máximo es 512 KB. +file.viewer.error=No se pudo leer el archivo: {0} +file.viewer.select.prompt=Selecciona un archivo del árbol superior para previsualizar su contenido # Project Side Panel panel.tab.rag.sources=Fuentes RAG diff --git a/desktop-shared/src/main/resources/i18n/messages_fr.properties b/desktop-shared/src/main/resources/i18n/messages_fr.properties index cf139582..806e9146 100644 --- a/desktop-shared/src/main/resources/i18n/messages_fr.properties +++ b/desktop-shared/src/main/resources/i18n/messages_fr.properties @@ -487,6 +487,11 @@ chat.tools.button.disabled=Outils disponibles ({0} désactivés) chat.input.placeholder=Tapez votre message... (Entrée pour envoyer, Shift+Entrée pour une nouvelle ligne) chat.select.file=Sélectionner un ou plusieurs fichiers chat.attachment.remove=Supprimer la pièce jointe +chat.attachment.preview.loading=Chargement de l'aperçu… +chat.attachment.preview.file.not.found=(Fichier introuvable) +chat.attachment.preview.too.large=(Fichier trop volumineux pour l'aperçu) +chat.attachment.preview.cannot.read=(Impossible de lire le fichier : {0}) +chat.attachment.preview.more.lines={0} lignes supplémentaires # Sessions View sessions.title=Sessions de conversation @@ -876,7 +881,6 @@ knowledge.source.type.url=URL (contenu web) # MCP Server Management mcp.servers.title=Modèles de serveurs MCP mcp.servers.description=Gérez des modèles de serveurs Model Context Protocol pouvant être réutilisés entre plusieurs projets -mcp.servers.add=Ajouter un modèle de serveur mcp.servers.edit=Modifier le modèle mcp.servers.delete=Supprimer le modèle mcp.servers.delete.confirm.title=Supprimer le modèle de serveur MCP ? @@ -1100,6 +1104,23 @@ rag.tree.file.copy.path=Copier le chemin rag.tree.url.open=Ouvrir dans le navigateur rag.tree.url.copy=Copier l’URL rag.tree.remove=Supprimer le matériel de référence +rag.tree.file.preview=Aperçu du fichier +rag.tree.chat.select=Ajouter au chat +rag.tree.chat.deselect=Retirer du chat +rag.tree.chat.selected={0} fichier(s) sélectionné(s) +rag.tree.chat.clear=Effacer +rag.tree.chat.add=Ajouter au chat + +# File Viewer Pane +file.viewer.loading=Chargement... +file.viewer.lines={0} lignes +file.viewer.copy=Copier le contenu +file.viewer.close=Fermer la visionneuse +file.viewer.open.external=Ouvrir en externe +file.viewer.binary=Fichier binaire — impossible de prévisualiser sous forme de texte. +file.viewer.too.large=Le fichier est trop volumineux pour être prévisualisé ({0} KB). Le maximum est de 512 KB. +file.viewer.error=Impossible de lire le fichier : {0} +file.viewer.select.prompt=Sélectionnez un fichier dans l'arborescence ci-dessus pour prévisualiser son contenu # Project Side Panel panel.tab.rag.sources=Sources RAG diff --git a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties index 0b0adf9c..ff06de7b 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties @@ -487,6 +487,11 @@ chat.tools.button.disabled=利用可能なツール({0} 無効) chat.input.placeholder=メッセージを入力...(Enter 送信、Shift+Enter 改行) chat.select.file=ファイルを選択(複数可) chat.attachment.remove=添付ファイルを削除 +chat.attachment.preview.loading=プレビューを読み込み中… +chat.attachment.preview.file.not.found=(ファイルが見つかりません) +chat.attachment.preview.too.large=(ファイルが大きすぎてプレビューできません) +chat.attachment.preview.cannot.read=(ファイルを読み込めません: {0}) +chat.attachment.preview.more.lines=さらに {0} 行 # Sessions View sessions.title=チャットセッション @@ -876,7 +881,6 @@ knowledge.source.type.url=URL(ウェブコンテンツ) # MCP Server Management mcp.servers.title=MCP サーバーテンプレート mcp.servers.description=プロジェクト全体で使用できる Model Context Protocol サーバーテンプレートを管理します -mcp.servers.add=サーバーテンプレートを追加 mcp.servers.edit=テンプレートを編集 mcp.servers.delete=テンプレートを削除 mcp.servers.delete.confirm.title=MCP サーバーテンプレートを削除しますか? @@ -1100,6 +1104,23 @@ rag.tree.file.copy.path=パスをコピー rag.tree.url.open=ブラウザで開く rag.tree.url.copy=URLをコピー rag.tree.remove=参照資料を削除 +rag.tree.file.preview=ファイルのプレビュー +rag.tree.chat.select=チャットに追加 +rag.tree.chat.deselect=チャットから削除 +rag.tree.chat.selected={0} 個のファイルを選択中 +rag.tree.chat.clear=クリア +rag.tree.chat.add=チャットに追加 + +# File Viewer Pane +file.viewer.loading=読み込み中... +file.viewer.lines={0} 行 +file.viewer.copy=コンテンツをコピー +file.viewer.close=ビューアーを閉じる +file.viewer.open.external=外部で開く +file.viewer.binary=バイナリファイル — テキストとしてプレビューできません。 +file.viewer.too.large=ファイルが大きすぎてプレビューできません({0} KB)。最大サイズは 512 KB です。 +file.viewer.error=ファイルを読み込めませんでした: {0} +file.viewer.select.prompt=上のツリーからファイルを選択して内容をプレビューします # Project Side Panel panel.tab.rag.sources=RAGソース diff --git a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties index 1ca44188..fa1b9bee 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties @@ -488,6 +488,11 @@ chat.tools.button.disabled=사용 가능한 도구 ({0} 비활성화됨) chat.input.placeholder=메시지 입력... (Enter 전송, Shift+Enter 줄바꿈) chat.select.file=파일 선택 (여러 개 선택 가능) chat.attachment.remove=첨부 파일 삭제 +chat.attachment.preview.loading=미리보기 불러오는 중… +chat.attachment.preview.file.not.found=(파일을 찾을 수 없음) +chat.attachment.preview.too.large=(파일이 너무 커서 미리볼 수 없음) +chat.attachment.preview.cannot.read=(파일을 읽을 수 없음: {0}) +chat.attachment.preview.more.lines={0}줄 더 # Sessions View sessions.title=채팅 세션 @@ -876,7 +881,6 @@ knowledge.source.type.url=URL (웹 콘텐츠) # MCP Server Management mcp.servers.title=MCP 서버 템플릿 mcp.servers.description=프로젝트 전반에서 재사용할 수 있는 Model Context Protocol 서버 템플릿을 관리합니다 -mcp.servers.add=서버 템플릿 추가 mcp.servers.edit=템플릿 편집 mcp.servers.delete=템플릿 삭제 mcp.servers.delete.confirm.title=MCP 서버 템플릿을 삭제할까요? @@ -1100,6 +1104,23 @@ rag.tree.file.copy.path=경로 복사 rag.tree.url.open=브라우저에서 열기 rag.tree.url.copy=URL 복사 rag.tree.remove=참고 자료 삭제 +rag.tree.file.preview=파일 미리보기 +rag.tree.chat.select=채팅에 추가 +rag.tree.chat.deselect=채팅에서 제거 +rag.tree.chat.selected={0}개의 파일 선택됨 +rag.tree.chat.clear=지우기 +rag.tree.chat.add=채팅에 추가 + +# File Viewer Pane +file.viewer.loading=로딩 중... +file.viewer.lines={0}줄 +file.viewer.copy=내용 복사 +file.viewer.close=뷰어 닫기 +file.viewer.open.external=외부에서 열기 +file.viewer.binary=바이너리 파일 — 텍스트로 미리 볼 수 없습니다. +file.viewer.too.large=파일이 너무 커서 미리 볼 수 없습니다({0} KB). 최대 크기는 512 KB입니다. +file.viewer.error=파일을 읽을 수 없습니다: {0} +file.viewer.select.prompt=위 트리에서 파일을 선택하여 내용을 미리 보세요 # Project Side Panel panel.tab.rag.sources=RAG 소스 diff --git a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties index ecd72e8e..7a772bc1 100644 --- a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties @@ -488,6 +488,11 @@ chat.tools.button.disabled=Ferramentas disponíveis ({0} desativadas) chat.input.placeholder=Digite sua mensagem... (Enter para enviar, Shift+Enter para nova linha) chat.select.file=Selecionar Arquivo(s) chat.attachment.remove=Remover anexo +chat.attachment.preview.loading=Carregando visualização… +chat.attachment.preview.file.not.found=(Arquivo não encontrado) +chat.attachment.preview.too.large=(Arquivo muito grande para visualização) +chat.attachment.preview.cannot.read=(Não é possível ler o arquivo: {0}) +chat.attachment.preview.more.lines=mais {0} linhas # Sessions View sessions.title=Sessões de Conversa @@ -876,7 +881,6 @@ knowledge.source.type.url=URL (conteúdo da web) # MCP Server Management mcp.servers.title=Modelos de servidores MCP mcp.servers.description=Gerencie modelos de servidores do Model Context Protocol que podem ser reutilizados entre projetos -mcp.servers.add=Adicionar modelo de servidor mcp.servers.edit=Editar modelo mcp.servers.delete=Excluir modelo mcp.servers.delete.confirm.title=Excluir modelo de servidor MCP? @@ -1100,6 +1104,23 @@ rag.tree.file.copy.path=Copiar caminho rag.tree.url.open=Abrir no navegador rag.tree.url.copy=Copiar URL rag.tree.remove=Remover material de referência +rag.tree.file.preview=Visualizar arquivo +rag.tree.chat.select=Adicionar ao chat +rag.tree.chat.deselect=Remover do chat +rag.tree.chat.selected={0} arquivo(s) selecionado(s) +rag.tree.chat.clear=Limpar +rag.tree.chat.add=Adicionar ao chat + +# File Viewer Pane +file.viewer.loading=Carregando... +file.viewer.lines={0} linhas +file.viewer.copy=Copiar conteúdo +file.viewer.close=Fechar visualizador +file.viewer.open.external=Abrir externamente +file.viewer.binary=Arquivo binário — não é possível visualizar como texto. +file.viewer.too.large=O arquivo é muito grande para visualização ({0} KB). O máximo é 512 KB. +file.viewer.error=Não foi possível ler o arquivo: {0} +file.viewer.select.prompt=Selecione um arquivo na árvore acima para visualizar seu conteúdo # Project Side Panel panel.tab.rag.sources=Fontes RAG diff --git a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties index 6f264915..79b7c070 100644 --- a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties @@ -484,6 +484,11 @@ chat.tools.button.disabled=Công cụ khả dụng ({0} bị vô hiệu hóa) chat.input.placeholder=Nhập tin nhắn... (Enter để gửi, Shift+Enter để xuống dòng) chat.select.file=Chọn tệp (nhiều tệp) chat.attachment.remove=Xóa tệp đính kèm +chat.attachment.preview.loading=Đang tải bản xem trước… +chat.attachment.preview.file.not.found=(Không tìm thấy tệp) +chat.attachment.preview.too.large=(Tệp quá lớn để xem trước) +chat.attachment.preview.cannot.read=(Không thể đọc tệp: {0}) +chat.attachment.preview.more.lines=thêm {0} dòng # Sessions View sessions.title=Các phiên trò chuyện @@ -875,7 +880,6 @@ knowledge.source.type.url=URL (nội dung web) # MCP Server Management mcp.servers.title=Mẫu máy chủ MCP mcp.servers.description=Quản lý các mẫu máy chủ Model Context Protocol có thể tái sử dụng trên nhiều dự án -mcp.servers.add=Thêm mẫu máy chủ mcp.servers.edit=Chỉnh sửa mẫu mcp.servers.delete=Xóa mẫu mcp.servers.delete.confirm.title=Xóa mẫu máy chủ MCP? @@ -1099,6 +1103,23 @@ rag.tree.file.copy.path=Sao chép đường dẫn rag.tree.url.open=Mở trong trình duyệt rag.tree.url.copy=Sao chép URL rag.tree.remove=Xóa tài liệu tham khảo +rag.tree.file.preview=Xem trước tệp +rag.tree.chat.select=Thêm vào Chat +rag.tree.chat.deselect=Xóa khỏi Chat +rag.tree.chat.selected=Đã chọn {0} tệp +rag.tree.chat.clear=Xóa +rag.tree.chat.add=Thêm vào Chat + +# File Viewer Pane +file.viewer.loading=Đang tải... +file.viewer.lines={0} dòng +file.viewer.copy=Sao chép nội dung +file.viewer.close=Đóng trình xem +file.viewer.open.external=Mở bên ngoài +file.viewer.binary=Tệp nhị phân — không thể xem trước dưới dạng văn bản. +file.viewer.too.large=Tệp quá lớn để xem trước ({0} KB). Tối đa là 512 KB. +file.viewer.error=Không thể đọc tệp: {0} +file.viewer.select.prompt=Chọn một tệp từ cây bên trên để xem trước nội dung # Project Side Panel panel.tab.rag.sources=Nguồn RAG diff --git a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties index 215dca4b..8f77b28e 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties @@ -488,6 +488,11 @@ chat.tools.button.disabled=可用工具({0} 已禁用) chat.input.placeholder=输入消息...(Enter 发送,Shift+Enter 换行) chat.select.file=选择文件(可多选) chat.attachment.remove=移除附件 +chat.attachment.preview.loading=正在加载预览… +chat.attachment.preview.file.not.found=(找不到文件) +chat.attachment.preview.too.large=(文件太大,无法预览) +chat.attachment.preview.cannot.read=(无法读取文件:{0}) +chat.attachment.preview.more.lines=还有 {0} 行 # Sessions View sessions.title=聊天会话 @@ -877,7 +882,6 @@ knowledge.source.type.url=URL(网页内容) # MCP Server Management mcp.servers.title=MCP 服务器模板 mcp.servers.description=管理可在多个项目中复用的 Model Context Protocol 服务器模板 -mcp.servers.add=添加服务器模板 mcp.servers.edit=编辑模板 mcp.servers.delete=删除模板 mcp.servers.delete.confirm.title=删除 MCP 服务器模板? @@ -1101,6 +1105,23 @@ rag.tree.file.copy.path=复制路径 rag.tree.url.open=在浏览器中打开 rag.tree.url.copy=复制 URL rag.tree.remove=删除参考资料 +rag.tree.file.preview=预览文件 +rag.tree.chat.select=添加到聊天 +rag.tree.chat.deselect=从聊天中移除 +rag.tree.chat.selected=已选择 {0} 个文件 +rag.tree.chat.clear=清除 +rag.tree.chat.add=添加到聊天 + +# File Viewer Pane +file.viewer.loading=加载中... +file.viewer.lines={0} 行 +file.viewer.copy=复制内容 +file.viewer.close=关闭查看器 +file.viewer.open.external=在外部打开 +file.viewer.binary=二进制文件 — 无法作为文本预览。 +file.viewer.too.large=文件太大,无法预览 ({0} KB)。最大为 512 KB。 +file.viewer.error=无法读取文件:{0} +file.viewer.select.prompt=从上方的树中选择一个文件以预览其内容 # Project Side Panel panel.tab.rag.sources=RAG 来源 diff --git a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties index f4d99cde..fc1cab5b 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties @@ -487,6 +487,11 @@ chat.tools.button.disabled=可用工具({0} 已停用) chat.input.placeholder=輸入訊息...(Enter 送出,Shift+Enter 換行) chat.select.file=選擇檔案(可多選) chat.attachment.remove=移除附件 +chat.attachment.preview.loading=正在載入預覽… +chat.attachment.preview.file.not.found=(找不到檔案) +chat.attachment.preview.too.large=(檔案太大,無法預覽) +chat.attachment.preview.cannot.read=(無法讀取檔案:{0}) +chat.attachment.preview.more.lines=還有 {0} 行 # Sessions View sessions.title=對話工作階段 @@ -878,7 +883,6 @@ knowledge.source.type.url=URL(網頁內容) # MCP Server Management mcp.servers.title=MCP 伺服器範本 mcp.servers.description=管理可在多個專案中重複使用的 Model Context Protocol 伺服器範本 -mcp.servers.add=新增伺服器範本 mcp.servers.edit=編輯範本 mcp.servers.delete=刪除範本 mcp.servers.delete.confirm.title=刪除 MCP 伺服器範本? @@ -1102,6 +1106,23 @@ rag.tree.file.copy.path=複製路徑 rag.tree.url.open=在瀏覽器中開啟 rag.tree.url.copy=複製 URL rag.tree.remove=刪除參考資料 +rag.tree.file.preview=預覽檔案 +rag.tree.chat.select=新增至聊天 +rag.tree.chat.deselect=從聊天中移除 +rag.tree.chat.selected=已選取 {0} 個檔案 +rag.tree.chat.clear=清除 +rag.tree.chat.add=新增至聊天 + +# File Viewer Pane +file.viewer.loading=載入中... +file.viewer.lines={0} 行 +file.viewer.copy=複製內容 +file.viewer.close=關閉檢視器 +file.viewer.open.external=在外部打開 +file.viewer.binary=二進位檔案 — 無法作為文字預覽。 +file.viewer.too.large=檔案太大,無法預覽 ({0} KB)。最大為 512 KB。 +file.viewer.error=無法讀取檔案:{0} +file.viewer.select.prompt=從上方的樹狀結構中選擇一個檔案以預覽其內容 # Project Side Panel panel.tab.rag.sources=RAG 來源 diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 81f40a96..5f139116 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -49,8 +49,8 @@ val distPackageVersion = project.version.toString().substringBefore("-") dependencies { implementation(compose.desktop.currentOs) - implementation(compose.material3) - implementation(compose.materialIconsExtended) + implementation(libs.compose.material3) + implementation(libs.compose.material.icons.extended) implementation(project(":shared")) implementation(project(":desktop-shared")) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 91dcd2c5..39093b5d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,8 @@ sqlite = "3.51.3.0" hikaricp = "7.0.2" exposed = "1.2.0" compose = "1.10.3" +compose-material3 = "1.9.0" +compose-material-icons = "1.7.8" coil = "3.4.0" koin = "4.2.0" jlatexmath = "1.0.7" @@ -71,6 +73,10 @@ jediterm-core = { module = "org.jetbrains.jediterm:jediterm-core", version.ref = coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } +# Compose Material +compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-material3" } +compose-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "compose-material-icons" } + # Jackson jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } jackson-dataformat-yaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", version.ref = "jackson" } diff --git a/shared/src/main/kotlin/io/askimo/core/chat/util/FileContentExtractor.kt b/shared/src/main/kotlin/io/askimo/core/chat/util/FileContentExtractor.kt index 27c7adde..0e55e23b 100644 --- a/shared/src/main/kotlin/io/askimo/core/chat/util/FileContentExtractor.kt +++ b/shared/src/main/kotlin/io/askimo/core/chat/util/FileContentExtractor.kt @@ -160,6 +160,21 @@ object FileContentExtractor { return false } + /** + * Check if a file name (without needing the file on disk) refers to a plain-text file. + * Uses extension and known config-file names — no MIME detection. + * Suitable for UI decisions (e.g. whether to show an inline preview) where the file + * may not yet be read. + * + * @param fileName The file name (basename, with or without path) + * @return true if the extension or filename maps to a text/code file + */ + fun isTextFile(fileName: String): Boolean { + val ext = FileTypeSupport.getExtension(fileName).lowercase() + return ext in (FileTypeSupport.TEXT_EXTENSIONS + FileTypeSupport.CODE_EXTENSIONS) || + fileName.lowercase() in FileTypeSupport.CONFIG_EXTENSIONS + } + /** * Check if a file is a text-based file where line numbers are meaningful. * Returns false for binary formats like PDF, DOCX, etc.