diff --git a/admin/src/plugins/i18n.ts b/admin/src/plugins/i18n.ts index 07b10964..c00200fd 100644 --- a/admin/src/plugins/i18n.ts +++ b/admin/src/plugins/i18n.ts @@ -368,6 +368,8 @@ const messages = { zenMode: "Фокус-режим", exitZenMode: "Выйти из фокус-режима", zenSettings: "Настройки чата", + pastedLines: "{count} строк", + removePasted: "Удалить вложение", claudeCode: { enable: "Включить Claude Code", disable: "Выключить Claude Code", @@ -1699,6 +1701,8 @@ const messages = { zenMode: "Focus mode", exitZenMode: "Exit focus mode", zenSettings: "Chat settings", + pastedLines: "{count} lines", + removePasted: "Remove attachment", claudeCode: { enable: "Enable Claude Code", disable: "Disable Claude Code", @@ -3030,6 +3034,8 @@ const messages = { zenMode: "Фокус режимі", exitZenMode: "Фокус режимінен шығу", zenSettings: "Чат параметрлері", + pastedLines: "{count} жол", + removePasted: "Тіркемені жою", claudeCode: { enable: "Claude Code қосу", disable: "Claude Code өшіру", diff --git a/admin/src/utils/pasteDetect.ts b/admin/src/utils/pasteDetect.ts new file mode 100644 index 00000000..13113130 --- /dev/null +++ b/admin/src/utils/pasteDetect.ts @@ -0,0 +1,71 @@ +export interface PastedBlock { + id: string + content: string + language: string + languageLabel: string + lineCount: number +} + +export const PASTE_THRESHOLD_LINES = 5 +export const PASTE_THRESHOLD_CHARS = 500 + +export function shouldTreatAsPaste(text: string): boolean { + const lineCount = text.split('\n').length + return lineCount >= PASTE_THRESHOLD_LINES || text.length >= PASTE_THRESHOLD_CHARS +} + +const LANGUAGE_RULES: Array<{ key: string; label: string; test: (t: string) => boolean }> = [ + { key: 'python', label: 'Python', test: t => /^(import |from .+ import |def |class |if __name__|@\w+)/.test(t) || /\bself\b/.test(t) }, + { key: 'typescript', label: 'TypeScript', test: t => /^(import .+ from |export (interface|type|const|function|class|enum)|interface \w+|type \w+ =)/.test(t) || /: (string|number|boolean|void)\b/.test(t) }, + { key: 'javascript', label: 'JavaScript', test: t => /^(import |export |const |let |var |function |class |module\.exports)/.test(t) || /=>\s*[{(]/.test(t) }, + { key: 'tsx', label: 'TSX', test: t => /^import .+ from/.test(t) && /<\w+[\s/>]/.test(t) }, + { key: 'jsx', label: 'JSX', test: t => /^(import |const |function )/.test(t) && /<\w+[\s/>]/.test(t) }, + { key: 'php', label: 'PHP', test: t => /^<\?php|^\$\w+\s*=|\bfunction\s+\w+\s*\(/.test(t) && /;$/.test(t.split('\n')[0] || '') }, + { key: 'java', label: 'Java', test: t => /^(package |import java\.|public (class|interface|enum)|private |protected )/.test(t) }, + { key: 'go', label: 'Go', test: t => /^(package |import \(|func |type \w+ struct)/.test(t) }, + { key: 'rust', label: 'Rust', test: t => /^(use |mod |fn |pub (fn|struct|enum|mod)|impl |let mut |#\[)/.test(t) }, + { key: 'sql', label: 'SQL', test: t => /^(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|WITH)\b/i.test(t) }, + { key: 'html', label: 'HTML', test: t => /^ /^(\.|#|@media|@import|:root|\w+\s*\{)/.test(t) && /\{[\s\S]*\}/.test(t) }, + { key: 'bash', label: 'Bash', test: t => /^(#!\/bin\/(ba)?sh|set -|export |if \[|for \w+ in|sudo |apt |npm |yarn |pip )/.test(t) }, + { key: 'yaml', label: 'YAML', test: t => /^\w+:\s*(\n|$)/.test(t) && !/[{};]/.test(t.split('\n')[0] || '') }, + { key: 'json', label: 'JSON', test: t => /^\s*[[{]/.test(t) && (() => { try { JSON.parse(t); return true } catch { return false } })() }, + { key: 'dockerfile', label: 'Dockerfile', test: t => /^(FROM |RUN |CMD |COPY |WORKDIR |EXPOSE |ENV |ARG |ENTRYPOINT )/i.test(t) }, + { key: 'markdown', label: 'Markdown', test: t => /^(#{1,6} |\* |- |\d+\. |> |```|\[.+\]\(.+\))/.test(t) }, +] + +export function detectLanguage(text: string): { key: string; label: string } { + const trimmed = text.trim() + for (const rule of LANGUAGE_RULES) { + if (rule.test(trimmed)) { + return { key: rule.key, label: rule.label } + } + } + return { key: 'text', label: 'Text' } +} + +let counter = 0 + +export function createPastedBlock(text: string): PastedBlock { + const { key, label } = detectLanguage(text) + return { + id: `paste-${Date.now()}-${++counter}`, + content: text, + language: key, + languageLabel: label, + lineCount: text.split('\n').length, + } +} + +export function buildMessageContent(text: string, blocks: PastedBlock[]): string { + if (blocks.length === 0) return text + + const parts: string[] = [] + for (const block of blocks) { + parts.push(`\`\`\`${block.language}\n${block.content}\n\`\`\``) + } + if (text) { + parts.push(text) + } + return parts.join('\n\n') +} diff --git a/admin/src/views/ChatView.vue b/admin/src/views/ChatView.vue index 8d8a71e4..81a743f2 100644 --- a/admin/src/views/ChatView.vue +++ b/admin/src/views/ChatView.vue @@ -84,6 +84,7 @@ import { claudeCodeApi, type CcProject, type CcProjectInput } from '@/api/claude import { kanbanApi, type KanbanTask } from '@/api/kanban' import { useResizablePanel } from '@/composables/useResizablePanel' import { getChatEmoji } from '@/utils/chatEmoji' +import { shouldTreatAsPaste, createPastedBlock, buildMessageContent, type PastedBlock } from '@/utils/pasteDetect' import { useChatFullscreenStore } from '@/stores/chatFullscreen' const { t } = useI18n() @@ -154,6 +155,20 @@ const { width: branchTreeWidth, startResize: startBranchResize, startTouchResize const { width: settingsWidth, startResize: startSettingsResize, startTouchResize: startSettingsTouchResize } = useResizablePanel('chat-settings-width', 500, 300, 800, 'left') const { width: artifactWidth, startResize: startArtifactResize, startTouchResize: startArtifactTouchResize } = useResizablePanel('chat-artifact-width', 500, 300, 800, 'left') +// Pasted content blocks +const pastedBlocks = ref([]) + +function onPaste(e: ClipboardEvent) { + const text = e.clipboardData?.getData('text/plain') + if (!text || !shouldTreatAsPaste(text)) return + e.preventDefault() + pastedBlocks.value.push(createPastedBlock(text)) +} + +function removePastedBlock(id: string) { + pastedBlocks.value = pastedBlocks.value.filter(b => b.id !== id) +} + // Artifact viewer state const showArtifact = ref(false) const activeArtifact = ref(null) @@ -205,6 +220,13 @@ function handleMessagesClick(e: MouseEvent) { return } + // Handle code-block expand/collapse toggle + const codeContainer = target.closest('.code-block-container') as HTMLElement | null + if (codeContainer && !target.closest('button') && !target.closest('a')) { + codeContainer.classList.toggle('expanded') + return + } + // Handle "save to context" button const saveBtn = target.closest('[data-artifact-save]') as HTMLElement | null if (saveBtn) { @@ -967,10 +989,13 @@ async function deleteCcSession(ccSessionId: string, title: string, event: Event) } function sendMessage() { - if (!inputMessage.value.trim() || !currentSessionId.value || isStreaming.value) return + const hasText = inputMessage.value.trim().length > 0 + const hasPaste = pastedBlocks.value.length > 0 + if ((!hasText && !hasPaste) || !currentSessionId.value || isStreaming.value) return - const content = inputMessage.value.trim() + const content = buildMessageContent(inputMessage.value.trim(), pastedBlocks.value) inputMessage.value = '' + pastedBlocks.value = [] if (messageInputRef.value) messageInputRef.value.style.height = 'auto' // Show user message immediately (optimistic) @@ -1042,9 +1067,12 @@ function sendMessage() { } function ccSendMessage() { - if (!inputMessage.value.trim() || cc.isProcessing.value) return - const prompt = inputMessage.value.trim() + const hasText = inputMessage.value.trim().length > 0 + const hasPaste = pastedBlocks.value.length > 0 + if ((!hasText && !hasPaste) || cc.isProcessing.value) return + const prompt = buildMessageContent(inputMessage.value.trim(), pastedBlocks.value) inputMessage.value = '' + pastedBlocks.value = [] if (messageInputRef.value) messageInputRef.value.style.height = 'auto' cc.sendMessage(prompt) nextTick(() => { @@ -2760,6 +2788,25 @@ watch(() => cc.isProcessing.value, (processing, wasProcesing) => { · {{ cc.currentModel.value }}
{{ cc.error.value }}
+ +
+
+ + {{ block.languageLabel }} + {{ t('chatView.pastedLines', { count: block.lineCount }) }} + +
+