feat: add project support

Add support for getting project list. OpenAI
calls these 'gizmos' and they include custom GPTs
and other apps.
Only projects are supported for now.
Add project selection box in Export All dialog.
This commit is contained in:
Jonathan Goren
2025-05-05 17:37:43 +03:00
committed by Pionxzh
parent d3b73cb4dd
commit 9ddd11f715
2 changed files with 96 additions and 14 deletions

View File

@@ -202,7 +202,21 @@ export interface ApiConversations {
items: ApiConversationItem[]
limit: number
offset: number
total: number
total: number | null
}
/// "Gizmos" are what OpenAI calls "projects" or other GPTs in the UI
export interface ApiGizmo {
// weird nesting but ok
gizmo: { gizmo: ApiProjectInfo }
conversations: { itmes: ApiConversationItem[] }
}
export interface ApiProjectInfo {
id: string
organization_id: string
display: { name: string; description: string }
// todo: support exporting project context
}
interface ApiAccountsCheckAccountDetail {
@@ -286,6 +300,8 @@ const sessionApi = urlcat(baseUrl, '/api/auth/session')
const conversationApi = (id: string) => urlcat(apiUrl, '/conversation/:id', { id })
const conversationsApi = (offset: number, limit: number) => urlcat(apiUrl, '/conversations', { offset, limit })
const fileDownloadApi = (id: string) => urlcat(apiUrl, '/files/:id/download', { id })
const projectsApi = () => urlcat(apiUrl, '/gizmos/snorlax/sidebar', { conversations_per_gizmo: 0 })
const projectConversationsApi = (gizmo: string, offset: number, limit: number) => urlcat(apiUrl, '/gizmos/:gizmo/conversations', { gizmo, cursor: offset, limit })
const accountsCheckApi = urlcat(apiUrl, '/accounts/check/v4-2023-04-27')
export async function getCurrentChatId(): Promise<string> {
@@ -390,19 +406,41 @@ export async function fetchConversation(chatId: string, shouldReplaceAssets: boo
}
}
async function fetchConversations(offset = 0, limit = 20): Promise<ApiConversations> {
export async function fetchProjects(): Promise<ApiProjectInfo[]> {
const url = projectsApi()
const { items } = await fetchApi<{ items: ApiGizmo[] }>(url)
return items.map(gizmo => (gizmo.gizmo.gizmo))
}
async function fetchConversations(offset = 0, limit = 20, project: string | null = null): Promise<ApiConversations> {
if (project) {
return fetchProjectConversations(project, offset, limit)
}
const url = conversationsApi(offset, limit)
return fetchApi(url)
}
export async function fetchAllConversations(): Promise<ApiConversationItem[]> {
async function fetchProjectConversations(project: string, offset = 0, limit = 20): Promise<ApiConversations> {
const url = projectConversationsApi(project, offset, limit)
const { items } = await fetchApi< { items: ApiConversationItem[]; cursor: number | null }>(url)
return {
has_missing_conversations: false,
items,
limit,
offset,
total: null,
}
}
export async function fetchAllConversations(project: string | null = null): Promise<ApiConversationItem[]> {
const conversations: ApiConversationItem[] = []
const limit = 100
const limit = project === null ? 100 : 50 // gizmos api uses a smaller limit
let offset = 0
while (true) {
const result = await fetchConversations(offset, limit)
const result = await fetchConversations(offset, limit, project)
conversations.push(...result.items)
if (offset + limit >= result.total) break
if (result.total !== null && offset + limit >= result.total) break
if (result.items.length === 0) break
if (offset + limit >= 1000) break
offset += limit
}
@@ -506,6 +544,8 @@ export interface ConversationResult {
createTime: number
updateTime: number
conversationNodes: ConversationNode[]
projectName?: string
projectId?: string
}
const ModelMapping: { [key in ModelSlug]: string } & { [key: string]: string } = {

View File

@@ -1,7 +1,7 @@
import * as Dialog from '@radix-ui/react-dialog'
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
import { useTranslation } from 'react-i18next'
import { archiveConversation, deleteConversation, fetchAllConversations, fetchConversation } from '../api'
import { archiveConversation, deleteConversation, fetchAllConversations, fetchConversation, fetchProjects } from '../api'
import { exportAllToHtml } from '../exporter/html'
import { exportAllToJson, exportAllToOfficialJson } from '../exporter/json'
import { exportAllToMarkdown } from '../exporter/markdown'
@@ -9,10 +9,42 @@ import { RequestQueue } from '../utils/queue'
import { CheckBox } from './CheckBox'
import { IconCross, IconUpload } from './Icons'
import { useSettingContext } from './SettingContext'
import type { ApiConversationItem, ApiConversationWithId } from '../api'
import type { ApiConversationItem, ApiConversationWithId, ApiProjectInfo } from '../api'
import type { FC } from '../type'
import type { ChangeEvent } from 'preact/compat'
interface ProjectSelectProps {
projects: ApiProjectInfo[]
selected: ApiProjectInfo | null
setSelected: (selected: ApiProjectInfo | null) => void
disabled: boolean
}
const ProjectSelect: FC<ProjectSelectProps> = ({ projects, selected, setSelected, disabled }) => {
const { t } = useTranslation()
return (
<div className="flex items-center text-gray-600 dark:text-gray-300 flex justify-between mb-3">
{t('Select Project')}
<select
disabled={disabled}
className="Select"
value={selected?.id || ''}
onChange={(e) => {
const projectId = e.currentTarget.value
const project = projects.find(p => p.id === projectId)
setSelected(project || null)
}}
>
<option value="">{t('(no project)')}</option>
{projects.map(project => (
<option key={project.id} value={project.id}>{project.display.name}</option>
))}
</select>
</div>
)
}
interface ConversationSelectProps {
conversations: ApiConversationItem[]
selected: ApiConversationItem[]
@@ -47,7 +79,8 @@ const ConversationSelect: FC<ConversationSelectProps> = ({
<ul className="SelectList">
{loading && <li className="SelectItem">{t('Loading')}...</li>}
{error && <li className="SelectItem">{t('Error')}: {error}</li>}
{conversations.map(c => (
{!loading && !error
&& conversations.map(c => (
<li className="SelectItem" key={c.id}>
<CheckBox
label={c.title}
@@ -90,9 +123,11 @@ const DialogContent: FC<DialogContentProps> = ({ format }) => {
const [apiConversations, setApiConversations] = useState<ApiConversationItem[]>([])
const [localConversations, setLocalConversations] = useState<ApiConversationWithId[]>([])
const conversations = exportSource === 'API' ? apiConversations : localConversations
const [projects, setProjects] = useState<ApiProjectInfo[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [processing, setProcessing] = useState(false)
const [selectedProject, setSelectedProject] = useState<ApiProjectInfo | null>(null)
const [selected, setSelected] = useState<ApiConversationItem[]>([])
const [exportType, setExportType] = useState(exportAllOptions[0].label)
@@ -246,13 +281,19 @@ const DialogContent: FC<DialogContentProps> = ({ format }) => {
}, [disabled, selected, archiveQueue, t])
useEffect(() => {
setLoading(true)
fetchAllConversations()
.then(setApiConversations)
.catch(setError)
.finally(() => setLoading(false))
fetchProjects()
.then(setProjects)
.catch(err => setError(err.toString()))
}, [])
useEffect(() => {
setLoading(true)
fetchAllConversations(selectedProject?.id)
.then(setApiConversations)
.catch(err => setError(err.toString()))
.finally(() => setLoading(false))
}, [selectedProject])
return (
<>
<Dialog.Title className="DialogTitle">{t('Export Dialog Title')}</Dialog.Title>
@@ -276,6 +317,7 @@ const DialogContent: FC<DialogContentProps> = ({ format }) => {
{t('Export from API')}
</div>
)}
<ProjectSelect projects={projects} selected={selectedProject} setSelected={setSelectedProject} disabled={processing || loading} />
<ConversationSelect
conversations={conversations}
selected={selected}