diff --git a/.github/workflows/backend-release.yml b/.github/workflows/backend-release.yml new file mode 100644 index 00000000..119f7eae --- /dev/null +++ b/.github/workflows/backend-release.yml @@ -0,0 +1,73 @@ +name: Backend release + +on: + push: + branches: [main] + paths: + - 'application/version.py' + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: backend-release + cancel-in-progress: false + +jobs: + release: + if: github.repository == 'arc53/DocsGPT' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - name: Read version from application/version.py + id: ver + run: | + VERSION=$(python3 -c "g={}; exec(open('application/version.py').read(), g); print(g['__version__'])") + if [ -z "$VERSION" ]; then + echo "::error::Could not read __version__ from application/version.py" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Resolved version: $VERSION" + + - name: Check if tag already exists + id: check + env: + VERSION: ${{ steps.ver.outputs.version }} + run: | + if git ls-remote --tags --exit-code origin "refs/tags/$VERSION" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Tag $VERSION already exists on origin — skipping." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create and push tag + if: steps.check.outputs.exists == 'false' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.ver.outputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "$VERSION" + # Authenticate this single push via a one-shot tokenized remote URL + # instead of leaving GITHUB_TOKEN persisted in .git/config (see + # persist-credentials: false on the checkout step above). + git push \ + "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + "$VERSION" + + - name: Create GitHub release + if: steps.check.outputs.exists == 'false' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.ver.outputs.version }} + run: | + gh release create "$VERSION" \ + --title "v$VERSION" \ + --generate-notes diff --git a/AGENTS.md b/AGENTS.md index f9a603b8..74f25a35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,6 +145,27 @@ vale . - Avoid broad UI refactors unless the task explicitly asks for them. - Do not re-create components if we already have some in the app. +#### Icons + +DocsGPT historically mixed three icon sources: `lucide-react`, inline SVG components, and +`.svg` assets loaded via ``. For new code: + +1. **Prefer `lucide-react`** for standard UI affordances (close, chevron, search, trash, + plus, etc.). It tokenizes via `currentColor`, ships tree-shaken icons, and the codebase + already imports it in 30+ places. ``, ``, etc. +2. **Use `assets/.svg?react`** when you need a brand-specific or domain illustration + that doesn't exist in lucide (the app logo, robot fallback, retry arrow, send arrow, + etc.). Always set `fill="currentColor"` / `stroke="currentColor"` in the SVG file so + consumers can theme via Tailwind text classes. +3. **Avoid `` for new icons.** It blocks `currentColor` theming and + forces dark-variant duplicates (the audit removed several orphan dark/purple/white + variants in this branch). The pattern is acceptable for existing call sites — don't + bulk-migrate without a reason. + +Three pre-existing dark-variant pairs (`documentation`, `no-files`, `science-spark`) are +hand-tuned multi-color illustrations, not pure inverts; they keep their `-dark` companion +files until a per-illustration refactor. + ## PR readiness Before opening a PR: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index db144f91..3ab57f4f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,10 +8,13 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", "@reduxjs/toolkit": "^2.10.1", "chart.js": "^4.4.4", "class-variance-authority": "^0.7.1", diff --git a/frontend/package.json b/frontend/package.json index a9056883..d10a9a3b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,10 +21,13 @@ ] }, "dependencies": { + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", "@reduxjs/toolkit": "^2.10.1", "chart.js": "^4.4.4", "class-variance-authority": "^0.7.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 382d6abb..cee2f8f5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import './locale/i18n'; import { useState } from 'react'; -import { Outlet, Route, Routes } from 'react-router-dom'; +import { Outlet, Route, Routes, useLocation } from 'react-router-dom'; import Agents from './agents'; import SharedAgentGate from './agents/SharedAgentGate'; @@ -45,7 +45,7 @@ function MainLayout() {
@@ -58,27 +58,36 @@ function MainLayout() { } export default function App() { const [, , componentMounted] = useDarkTheme(); + const location = useLocation(); const [showNotification, setShowNotification] = useState(() => { const saved = localStorage.getItem('showNotification'); return saved ? JSON.parse(saved) : true; }); const notificationText = import.meta.env.VITE_NOTIFICATION_TEXT; const notificationLink = import.meta.env.VITE_NOTIFICATION_LINK; + // Hide the changelog banner on public share routes — those pages are + // embedded / shared externally and shouldn't carry product chrome. + const isPublicShareRoute = + location.pathname.startsWith('/share/') || + location.pathname.startsWith('/shared/'); if (!componentMounted) { return
; } return (
- {notificationLink && notificationText && showNotification && ( - { - setShowNotification(false); - localStorage.setItem('showNotification', 'false'); - }} - /> - )} + {notificationLink && + notificationText && + showNotification && + !isPublicShareRoute && ( + { + setShowNotification(false); + localStorage.setItem('showNotification', 'false'); + }} + /> + )} (undefined); + + useEffect(() => { + const loadModels = async () => { + if ( + (availableModels?.length ?? 0) > 0 && + lastLoadedTokenRef.current === token + ) { + return; + } + dispatch(setModelsLoading(true)); + try { + const response = await modelService.getModels(token); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + const data = await response.json(); + const models = data.models || []; + const transformed = modelService.transformModels(models); + + dispatch(setAvailableModels(transformed)); + lastLoadedTokenRef.current = token; + if (!selectedModel && transformed.length > 0) { + const defaultModel = + transformed.find((m: Model) => m.id === data.default_model_id) || + transformed[0]; + dispatch(setSelectedModel(defaultModel)); + } else if (selectedModel && transformed.length > 0) { + const isValid = transformed.find( + (m: Model) => m.id === selectedModel.id, + ); + if (!isValid) { + const defaultModel = + transformed.find((m: Model) => m.id === data.default_model_id) || + transformed[0]; + dispatch(setSelectedModel(defaultModel)); + } + } + } catch (error) { + console.error('Failed to load models:', error); + } finally { + dispatch(setModelsLoading(false)); + } + }; + + loadModels(); + }, [availableModels?.length, dispatch, selectedModel, token]); + + const hasModels = availableModels && (availableModels?.length ?? 0) > 0; + + return ( + + ); +} export default function Hero({ handleQuestion, @@ -21,7 +131,7 @@ export default function Hero({ }>; return ( -
+
{/* Header Section */}
@@ -30,7 +140,7 @@ export default function Hero({
{/* Model Selector */}
- +
@@ -44,9 +154,9 @@ export default function Hero({ - )} - {queries?.length > 0 && ( - - )} -
- DocsGPT -
+ {/* Icon rail (desktop only, when sidebar collapsed) */} + {!navOpen && !isMobile && !isTablet && ( +
+ + {queries?.length > 0 && ( + + )} + +
+ +
- } + )}
DocsGPT

- +
{conversations?.loading && !isDeletingConversation && ( -
- Loading conversations +
+
)} {recentAgents?.length > 0 ? ( @@ -445,10 +484,10 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { >
-

@@ -458,18 +497,24 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {

- +
))} @@ -524,9 +569,12 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {

{t('chats')}

- +
{(conversations.data ?? []).map((conversation) => ( @@ -639,25 +687,32 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
- -
+ +
DocsGPT
- dispatch(setModalStateDeleteConv(state))} + submitLabel={t('modals.deleteConv.delete')} + handleSubmit={handleDeleteAllConversations} + variant="danger" /> {uploadModalState === 'ACTIVE' && (

{t('pageNotFound.title')}

{t('pageNotFound.message')}

- + + {t('pageNotFound.goHome')} +

); diff --git a/frontend/src/agents/AgentCard.tsx b/frontend/src/agents/AgentCard.tsx index fe6a01e7..42968fae 100644 --- a/frontend/src/agents/AgentCard.tsx +++ b/frontend/src/agents/AgentCard.tsx @@ -1,4 +1,4 @@ -import { SyntheticEvent, useRef, useState } from 'react'; +import { SyntheticEvent, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -13,11 +13,25 @@ import Pin from '../assets/pin.svg'; import Trash from '../assets/red-trash.svg'; import ThreeDots from '../assets/three-dots.svg'; import UnPin from '../assets/unpin.svg'; -import AgentImage from '../components/AgentImage'; -import ContextMenu, { MenuOption } from '../components/ContextMenu'; +import { Avatar } from '../components/ui/avatar'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../components/ui/dropdown-menu'; import ConfirmationModal from '../modals/ConfirmationModal'; import MoveToFolderModal from '../modals/MoveToFolderModal'; import { ActiveState } from '../models/misc'; + +type AgentMenuOption = { + icon: string; + label: string; + onClick: (event: SyntheticEvent) => void; + variant: 'default' | 'destructive'; + iconWidth?: number; + iconHeight?: number; +}; import { selectAgents, selectToken, @@ -45,14 +59,11 @@ export default function AgentCard({ const token = useSelector(selectToken); const userAgents = useSelector(selectAgents); - const [isMenuOpen, setIsMenuOpen] = useState(false); const [deleteConfirmation, setDeleteConfirmation] = useState('INACTIVE'); const [moveModalState, setMoveModalState] = useState('INACTIVE'); - const menuRef = useRef(null); - - const menuOptionsConfig: Record = { + const menuOptionsConfig: Record = { template: [ { icon: Duplicate, @@ -61,7 +72,7 @@ export default function AgentCard({ e.stopPropagation(); handleDuplicate(); }, - variant: 'primary', + variant: 'default', iconWidth: 18, iconHeight: 18, }, @@ -74,7 +85,7 @@ export default function AgentCard({ e.stopPropagation(); navigate(`/agents/logs/${agent.id}`); }, - variant: 'primary', + variant: 'default', iconWidth: 14, iconHeight: 14, }, @@ -89,7 +100,7 @@ export default function AgentCard({ navigate(`/agents/edit/${agent.id}`); } }, - variant: 'primary', + variant: 'default', iconWidth: 14, iconHeight: 14, }, @@ -102,7 +113,7 @@ export default function AgentCard({ e.stopPropagation(); togglePin(); }, - variant: 'primary' as const, + variant: 'default' as const, iconWidth: 18, iconHeight: 18, }, @@ -114,9 +125,8 @@ export default function AgentCard({ onClick: (e: SyntheticEvent) => { e.stopPropagation(); setMoveModalState('ACTIVE'); - setIsMenuOpen(false); }, - variant: 'primary', + variant: 'default', iconWidth: 16, iconHeight: 15, }, @@ -127,7 +137,7 @@ export default function AgentCard({ e.stopPropagation(); setDeleteConfirmation('ACTIVE'); }, - variant: 'danger', + variant: 'destructive', iconWidth: 13, iconHeight: 13, }, @@ -140,7 +150,7 @@ export default function AgentCard({ e.stopPropagation(); navigate(`/agents/shared/${agent.shared_token}`); }, - variant: 'primary', + variant: 'default', iconWidth: 12, iconHeight: 12, }, @@ -151,7 +161,7 @@ export default function AgentCard({ e.stopPropagation(); togglePin(); }, - variant: 'primary', + variant: 'default', iconWidth: 18, iconHeight: 18, }, @@ -162,7 +172,7 @@ export default function AgentCard({ e.stopPropagation(); handleHideSharedAgent(); }, - variant: 'danger', + variant: 'destructive', iconWidth: 13, iconHeight: 13, }, @@ -251,36 +261,66 @@ export default function AgentCard({ }; return (
{ e.stopPropagation(); handleClick(); }} + onKeyDown={(e) => { + if ( + agent.status === 'published' && + (e.key === 'Enter' || e.key === ' ') + ) { + e.preventDefault(); + handleClick(); + } + }} > -
{ - e.stopPropagation(); - setIsMenuOpen(true); - }} - className="absolute top-4 right-4 z-10 cursor-pointer" - > - {'use-agent'} - -
+ + + + + + {menuOptions.map((option, index) => ( + e.stopPropagation()} + onSelect={(event) => { + option.onClick(event as unknown as SyntheticEvent); + }} + > + + {option.label} + + ))} + +
- {agent.status === 'draft' && (

{`(Draft)`}

@@ -289,11 +329,11 @@ export default function AgentCard({

{agent.name}

-

+

{agent.description}

diff --git a/frontend/src/agents/AgentLogs.tsx b/frontend/src/agents/AgentLogs.tsx index 04157412..ec9fc53e 100644 --- a/frontend/src/agents/AgentLogs.tsx +++ b/frontend/src/agents/AgentLogs.tsx @@ -43,7 +43,7 @@ export default function AgentLogs() { : `/agents/edit/${agentId}`; return ( -
+
- - +
+ + {t('agents.pageHeader.crumbs.agents')} @@ -110,7 +115,7 @@ export default function AgentPageHeader({