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
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ Import from `modules.core`: `EventBus`, `BaseEvent`, `TaskRegistry`, `TaskInfo`,
| `modules/crm/` | `service.py` | `AmoCRMService` |
| `modules/ecommerce/` | `service.py` | `WooCommerceService` |
| `modules/telephony/` | `service.py` | `GSMService` |
| `modules/google/` | `service.py`, `models.py` | `GoogleOAuthService` |

**Import pattern**: `from modules.chat.service import chat_service` (direct, preferred) or `from db.integration import async_chat_manager` (backward-compatible alias). Domain `__init__.py` files do NOT re-export services (see Known Issues #9).

Expand All @@ -204,6 +205,7 @@ Phase 3 migration complete: all 28 routers moved from `app/routers/` to domain m
| `modules/monitoring/` | `router_audit.py`, `router_usage.py`, `router_monitor.py` | `app/routers/audit.py`, `usage.py`, `monitor.py` |
| `modules/chat/` | `router.py` | `app/routers/chat.py` |
| `modules/llm/` | `router.py` | `app/routers/llm.py` |
| `modules/google/` | `router.py` (+ `callback_router`) | `app/routers/google.py` |

**Phase 4 routers** (extracted from `orchestrator.py`, not from `app/routers/`):

Expand Down Expand Up @@ -336,6 +338,9 @@ REDIS_URL=redis://localhost:6379/0 # Optional, graceful fallback
DEV_MODE=1 # Backend proxies to Vite dev server (:5173)
VECTOR_SEARCH_URL=http://localhost:8003 # Optional, Vector Search microservice
VECTOR_SEARCH_TOKEN= # Bearer token for Vector Search API
GOOGLE_CLIENT_ID= # Google OAuth 2.0 (Drive, Docs, Sheets, Gmail)
GOOGLE_CLIENT_SECRET= # Google OAuth 2.0 client secret
GOOGLE_REDIRECT_URI= # OAuth callback URL (default: {BASE_URL}/admin/oauth/google/callback)
```

## Deployment
Expand Down
50 changes: 50 additions & 0 deletions admin/src/api/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,37 @@ export interface GoogleOAuthStatus {
scopes: string[]
}

export interface GoogleDriveFile {
id: string
name: string
mimeType: string
modifiedTime: string | null
size: string | null
isFolder: boolean
}

export interface GoogleDriveListResponse {
files: GoogleDriveFile[]
nextPageToken?: string
}

export interface GoogleDocContent {
title: string
text: string
id: string
}

export interface GoogleSheetContent {
title: string
sheet: string
sheets: string[]
markdown: string
rows: number
id: string
}

export const googleApi = {
// OAuth
getAuthUrl: () =>
api.get<{ auth_url: string }>('/admin/google/auth-url'),

Expand All @@ -15,4 +45,24 @@ export const googleApi = {

disconnect: () =>
api.post<{ status: string }>('/admin/google/disconnect'),

// Drive
driveList: (folderId = 'root', query?: string, pageToken?: string) => {
const params = new URLSearchParams({ folder_id: folderId })
if (query) params.set('query', query)
if (pageToken) params.set('page_token', pageToken)
return api.get<GoogleDriveListResponse>(`/admin/google/drive/files?${params}`)
},

driveSearch: (query: string) =>
api.get<GoogleDriveListResponse>(`/admin/google/drive/search?query=${encodeURIComponent(query)}`),

// File content
getFileContent: (fileId: string, mimeType: string, sheetName?: string) => {
const params = new URLSearchParams({ mime_type: mimeType })
if (sheetName) params.set('sheet_name', sheetName)
return api.get<GoogleDocContent | GoogleSheetContent>(
`/admin/google/drive/file/${fileId}/content?${params}`
)
},
}
6 changes: 6 additions & 0 deletions admin/src/plugins/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1353,6 +1353,8 @@ const messages = {
connected: "Google аккаунт подключён",
disconnected: "Google аккаунт отключён",
connectionFailed: "Не удалось подключить Google",
searchFiles: "Поиск файлов...",
noFiles: "Нет файлов",
},
},
en: {
Expand Down Expand Up @@ -2705,6 +2707,8 @@ const messages = {
connected: "Google account connected",
disconnected: "Google account disconnected",
connectionFailed: "Failed to connect Google",
searchFiles: "Search files...",
noFiles: "No files",
},
},
kk: {
Expand Down Expand Up @@ -4057,6 +4061,8 @@ const messages = {
connected: "Google аккаунт қосылды",
disconnected: "Google аккаунт ажыратылды",
connectionFailed: "Google қосу сәтсіз аяқталды",
searchFiles: "Файлдарды іздеу...",
noFiles: "Файлдар жоқ",
},
},
};
Expand Down
176 changes: 174 additions & 2 deletions admin/src/views/ChatView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1501,6 +1501,119 @@ function addEmptyContextFile() {
editingFileContent.value = ''
}

// ── Google Drive picker ──────────────────────────────────────
const googleConnected = ref(false)
const showGoogleDrivePicker = ref(false)
const gdriveFiles = ref<import('@/api/google').GoogleDriveFile[]>([])
const gdriveLoading = ref(false)
const gdriveSearch = ref('')
const gdrivePath = ref<{ id: string; name: string }[]>([])
const gdriveCurrentFolder = ref('root')

async function checkGoogleConnection() {
try {
const { googleApi } = await import('@/api/google')
const status = await googleApi.getStatus()
googleConnected.value = status.connected
} catch {
googleConnected.value = false
}
}

async function loadGdriveFolder(folderId = 'root') {
gdriveLoading.value = true
gdriveCurrentFolder.value = folderId
try {
const { googleApi } = await import('@/api/google')
const result = await googleApi.driveList(folderId)
gdriveFiles.value = result.files
} catch {
gdriveFiles.value = []
} finally {
gdriveLoading.value = false
}
}

async function searchGoogleDrive() {
if (!gdriveSearch.value.trim()) {
loadGdriveFolder(gdriveCurrentFolder.value)
return
}
gdriveLoading.value = true
try {
const { googleApi } = await import('@/api/google')
const result = await googleApi.driveSearch(gdriveSearch.value)
gdriveFiles.value = result.files
gdrivePath.value = []
} catch {
gdriveFiles.value = []
} finally {
gdriveLoading.value = false
}
}

function navigateGdrive(folderId: string, breadcrumbIndex?: number) {
if (folderId === 'root') {
gdrivePath.value = []
} else if (breadcrumbIndex !== undefined) {
gdrivePath.value = gdrivePath.value.slice(0, breadcrumbIndex + 1)
}
gdriveSearch.value = ''
loadGdriveFolder(folderId)
}

function navigateGdriveInto(folder: import('@/api/google').GoogleDriveFile) {
gdrivePath.value.push({ id: folder.id, name: folder.name })
gdriveSearch.value = ''
loadGdriveFolder(folder.id)
}

async function attachGoogleFile(file: import('@/api/google').GoogleDriveFile) {
gdriveLoading.value = true
try {
const { googleApi } = await import('@/api/google')
const content = await googleApi.getFileContent(file.id, file.mimeType)
const text = 'markdown' in content ? content.markdown : ('text' in content ? content.text : '')
const title = content.title || file.name
contextFiles.value.push({ name: title, content: text })
autoSaveContextFiles()
showGoogleDrivePicker.value = false
toastStore.success(`${title} добавлен в контекст`)
} catch (e) {
toastStore.error('Не удалось загрузить файл из Google Drive')
} finally {
gdriveLoading.value = false
}
}

function gdriveIcon(file: import('@/api/google').GoogleDriveFile): string {
if (file.isFolder) return '\ud83d\udcc1'
const mt = file.mimeType
if (mt.includes('document')) return '\ud83d\udcd4'
if (mt.includes('spreadsheet')) return '\ud83d\udcca'
if (mt.includes('presentation')) return '\ud83d\udcfd\ufe0f'
if (mt.includes('pdf')) return '\ud83d\udcc4'
if (mt.includes('image')) return '\ud83d\uddbc\ufe0f'
return '\ud83d\udcc3'
}

function gdriveTypeLabel(mimeType: string): string {
if (mimeType.includes('document')) return 'Doc'
if (mimeType.includes('spreadsheet')) return 'Sheet'
if (mimeType.includes('presentation')) return 'Slides'
if (mimeType.includes('pdf')) return 'PDF'
return ''
}

// Load Google Drive files when picker opens
watch(showGoogleDrivePicker, (v) => {
if (v) {
gdrivePath.value = []
gdriveSearch.value = ''
loadGdriveFolder('root')
}
})

function editContextFile(index: number) {
editingFileIndex.value = index
editingFileName.value = contextFiles.value[index].name
Expand Down Expand Up @@ -1813,6 +1926,7 @@ onMounted(() => {
currentSessionId.value = sessions.value[0].id
}
fetchAllCcSessions()
checkGoogleConnection()
})

watch(sessions, (newSessions) => {
Expand Down Expand Up @@ -3232,7 +3346,7 @@ class="w-4 h-4 rounded border flex items-center justify-center shrink-0"
v-if="currentSessionId && !isReadOnly"
:class="[
'p-3 rounded-lg transition-colors shrink-0',
webSearchEnabled ? 'bg-blue-500/20 text-blue-400 hover:bg-blue-500/30' : 'bg-secondary text-muted-foreground hover:bg-secondary/80'
webSearchEnabled ? 'bg-orange-500/20 text-orange-400 hover:bg-orange-500/30' : 'bg-secondary text-muted-foreground hover:bg-secondary/80'
]"
:title="webSearchEnabled ? t('chatView.webSearchOn') : t('chatView.webSearchOff')"
@click="webSearchEnabled = !webSearchEnabled"
Expand Down Expand Up @@ -3916,7 +4030,7 @@ class="w-4 h-4 rounded border flex items-center justify-center shrink-0"
</div>

<!-- Action buttons -->
<div class="flex gap-2">
<div class="flex flex-wrap gap-2">
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs bg-secondary rounded-lg hover:bg-secondary/80 transition-colors"
@click="triggerContextFileUpload"
Expand All @@ -3931,6 +4045,64 @@ class="w-4 h-4 rounded border flex items-center justify-center shrink-0"
<Plus class="w-3.5 h-3.5" />
{{ t('chatView.emptyFile') }}
</button>
<button
v-if="googleConnected"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs bg-secondary rounded-lg hover:bg-secondary/80 transition-colors"
@click="showGoogleDrivePicker = true"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.27-4.74 3.27-8.1z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
Google Drive
</button>
</div>

<!-- Google Drive Picker Modal -->
<div v-if="showGoogleDrivePicker" class="mt-3 p-3 bg-secondary/50 rounded-lg border border-border">
<div class="flex items-center justify-between mb-2">
<h4 class="text-sm font-medium">Google Drive</h4>
<button class="p-1 hover:bg-secondary rounded" @click="showGoogleDrivePicker = false">
<X class="w-4 h-4" />
</button>
</div>
<!-- Search -->
<div class="mb-2">
<input
v-model="gdriveSearch"
type="text"
:placeholder="t('google.searchFiles')"
class="w-full px-3 py-1.5 text-sm bg-background border border-border rounded-lg focus:ring-1 focus:ring-primary"
@keyup.enter="searchGoogleDrive"
/>
</div>
<!-- Breadcrumbs -->
<div v-if="gdrivePath.length > 0" class="flex items-center gap-1 text-xs text-muted-foreground mb-2 flex-wrap">
<button class="hover:text-foreground" @click="navigateGdrive('root')">Drive</button>
<template v-for="(crumb, i) in gdrivePath" :key="crumb.id">
<span>/</span>
<button class="hover:text-foreground truncate max-w-[120px]" @click="navigateGdrive(crumb.id, i)">{{ crumb.name }}</button>
</template>
</div>
<!-- File list -->
<div v-if="gdriveLoading" class="py-4 text-center text-sm text-muted-foreground">
<Loader2 class="w-4 h-4 inline animate-spin mr-1" />
{{ t('common.loading') }}
</div>
<div v-else-if="gdriveFiles.length === 0" class="py-4 text-center text-sm text-muted-foreground">
{{ t('google.noFiles') }}
</div>
<div v-else class="max-h-48 overflow-y-auto space-y-0.5">
<button
v-for="file in gdriveFiles"
:key="file.id"
class="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-secondary flex items-center gap-2 transition-colors"
@click="file.isFolder ? navigateGdriveInto(file) : attachGoogleFile(file)"
>
<span class="text-base shrink-0">{{ gdriveIcon(file) }}</span>
<span class="truncate flex-1">{{ file.name }}</span>
<span v-if="!file.isFolder" class="text-xs text-muted-foreground shrink-0">
{{ gdriveTypeLabel(file.mimeType) }}
</span>
</button>
</div>
</div>

<div class="flex justify-end gap-2">
Expand Down
Binary file modified mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
<color name="ic_launcher_background">#1A1308</color>
</resources>
Loading
Loading