Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions admin/src/plugins/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,8 @@ const messages = {
zenMode: "Фокус-режим",
exitZenMode: "Выйти из фокус-режима",
zenSettings: "Настройки чата",
pastedLines: "{count} строк",
removePasted: "Удалить вложение",
claudeCode: {
enable: "Включить Claude Code",
disable: "Выключить Claude Code",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -3030,6 +3034,8 @@ const messages = {
zenMode: "Фокус режимі",
exitZenMode: "Фокус режимінен шығу",
zenSettings: "Чат параметрлері",
pastedLines: "{count} жол",
removePasted: "Тіркемені жою",
claudeCode: {
enable: "Claude Code қосу",
disable: "Claude Code өшіру",
Expand Down
71 changes: 71 additions & 0 deletions admin/src/utils/pasteDetect.ts
Original file line number Diff line number Diff line change
@@ -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 => /^<!DOCTYPE|^<html|^<div|^<section|^<template/i.test(t) },
{ key: 'css', label: 'CSS', 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')
}
100 changes: 94 additions & 6 deletions admin/src/views/ChatView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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<PastedBlock[]>([])

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<Artifact | null>(null)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -2760,6 +2788,25 @@ watch(() => cc.isProcessing.value, (processing, wasProcesing) => {
<span v-if="cc.currentModel.value" class="text-muted-foreground ml-1">· {{ cc.currentModel.value }}</span>
</div>
<div v-if="cc.error.value" class="mb-2 text-xs text-red-500">{{ cc.error.value }}</div>
<!-- Pasted blocks chips -->
<div v-if="pastedBlocks.length" class="flex flex-wrap gap-2 mb-2">
<div
v-for="block in pastedBlocks"
:key="block.id"
class="flex items-center gap-1.5 px-2.5 py-1.5 bg-secondary rounded-lg border border-border text-xs"
>
<FileText class="w-3.5 h-3.5 text-green-500 shrink-0" />
<span class="font-medium text-green-400">{{ block.languageLabel }}</span>
<span class="text-muted-foreground">{{ t('chatView.pastedLines', { count: block.lineCount }) }}</span>
<button
class="ml-1 p-0.5 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
:title="t('chatView.removePasted')"
@click="removePastedBlock(block.id)"
>
<X class="w-3 h-3" />
</button>
</div>
</div>
<div class="flex gap-3 items-end">
<textarea
ref="messageInputRef"
Expand All @@ -2771,6 +2818,7 @@ watch(() => cc.isProcessing.value, (processing, wasProcesing) => {
@keydown.enter.exact.prevent="ccSendMessage"
@keydown.ctrl.enter.prevent="ccSendMessage"
@input="onInputAutoResize"
@paste="onPaste"
/>
<!-- Abort button (while processing) -->
<button
Expand All @@ -2784,7 +2832,7 @@ watch(() => cc.isProcessing.value, (processing, wasProcesing) => {
<!-- Send button -->
<button
v-else
:disabled="!inputMessage.trim() || !cc.isConnected.value"
:disabled="(!inputMessage.trim() && !pastedBlocks.length) || !cc.isConnected.value"
class="p-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 transition-colors"
@click="ccSendMessage"
>
Expand Down Expand Up @@ -2812,6 +2860,25 @@ watch(() => cc.isProcessing.value, (processing, wasProcesing) => {
inputPosition === 'bottom' ? 'border-t border-border order-last' + (fullscreenStore.isFullscreen ? '' : ' pb-24') : 'border-b border-border'
]"
>
<!-- Pasted blocks chips -->
<div v-if="pastedBlocks.length" class="flex flex-wrap gap-2 mb-2 max-w-3xl mx-auto">
<div
v-for="block in pastedBlocks"
:key="block.id"
class="flex items-center gap-1.5 px-2.5 py-1.5 bg-secondary rounded-lg border border-border text-xs"
>
<FileText class="w-3.5 h-3.5 text-primary shrink-0" />
<span class="font-medium">{{ block.languageLabel }}</span>
<span class="text-muted-foreground">{{ t('chatView.pastedLines', { count: block.lineCount }) }}</span>
<button
class="ml-1 p-0.5 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
:title="t('chatView.removePasted')"
@click="removePastedBlock(block.id)"
>
<X class="w-3 h-3" />
</button>
</div>
</div>
<div
:class="[
'flex gap-3 max-w-3xl mx-auto',
Expand All @@ -2828,6 +2895,7 @@ watch(() => cc.isProcessing.value, (processing, wasProcesing) => {
@keydown.enter.exact.prevent="sendMessage"
@keydown.ctrl.enter.prevent="sendMessage"
@input="onInputAutoResize"
@paste="onPaste"
/>
<!-- Microphone button -->
<button
Expand All @@ -2847,7 +2915,7 @@ watch(() => cc.isProcessing.value, (processing, wasProcesing) => {
</button>
<!-- Send button -->
<button
:disabled="!inputMessage.trim() || isStreaming || isRecording"
:disabled="(!inputMessage.trim() && !pastedBlocks.length) || isStreaming || isRecording"
class="p-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors"
@click="sendMessage"
>
Expand Down Expand Up @@ -3540,4 +3608,24 @@ watch(() => cc.isProcessing.value, (processing, wasProcesing) => {
padding-left: 1rem;
padding-right: 1rem;
}

/* Collapsible code blocks in user messages */
.claude-message-user :deep(.code-block-container) {
position: relative;
cursor: pointer;
}
.claude-message-user :deep(.code-block-container:not(.expanded)) pre {
max-height: 200px;
overflow: hidden;
}
.claude-message-user :deep(.code-block-container:not(.expanded)) pre::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 60px;
background: linear-gradient(transparent, hsl(var(--secondary)));
pointer-events: none;
}
</style>
17 changes: 10 additions & 7 deletions ai-secretary.service
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[Unit]
Description=AI Secretary Orchestrator
After=network.target docker.service
Wants=docker.service
Description=AI Secretary System (vLLM + XTTS + Orchestrator)
After=network.target
Wants=network.target

[Service]
Type=simple
Expand All @@ -10,11 +10,14 @@ Group=shaerware
WorkingDirectory=/home/shaerware/Documents/AI_Secretary_System
EnvironmentFile=/home/shaerware/Documents/AI_Secretary_System/.env
Environment=COQUI_TOS_AGREED=1
ExecStart=/home/shaerware/Documents/AI_Secretary_System/.venv/bin/python orchestrator.py
ExecStart=/bin/bash /home/shaerware/Documents/AI_Secretary_System/start_gpu.sh
ExecStop=/bin/kill -TERM $MAINPID
KillMode=mixed
KillSignal=SIGTERM
TimeoutStartSec=300
TimeoutStopSec=30
Restart=on-failure
RestartSec=10
StandardOutput=append:/home/shaerware/Documents/AI_Secretary_System/logs/orchestrator.log
StandardError=append:/home/shaerware/Documents/AI_Secretary_System/logs/orchestrator.log
RestartSec=30

[Install]
WantedBy=multi-user.target
16 changes: 16 additions & 0 deletions mobile/src/api/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,17 @@ export const chatApi = {
`/admin/chat/sessions/${sessionId}/messages/${messageId}`,
),

editMessage: (sessionId: string, messageId: string, content: string) =>
api.put<{ message: ChatMessage }>(
`/admin/chat/sessions/${sessionId}/messages/${messageId}`,
{ content },
),

summarizeBranch: (sessionId: string, messageId: string) =>
api.post<{ summary: string }>(
`/admin/chat/sessions/${sessionId}/messages/${messageId}/summarize`,
),

// Branches
getBranches: (sessionId: string) =>
api.get<{ branches: BranchNode[] }>(
Expand All @@ -124,6 +135,11 @@ export const chatApi = {
`/admin/chat/sessions/${sessionId}/branches/new`,
),

regenerateResponse: (sessionId: string, messageId: string) =>
api.post<{ status: string }>(
`/admin/chat/sessions/${sessionId}/messages/${messageId}/regenerate`,
),

streamMessage: (
sessionId: string,
content: string,
Expand Down
Loading
Loading