Compare commits

..

5 Commits

Author SHA1 Message Date
shiyu
280bedcf1a chore: Update version to v1.1.6 2025-09-07 17:00:25 +08:00
shiyu
b03f2619ca feat: Add vector database clearing 2025-09-07 16:48:14 +08:00
Kuenpan Foo
72403d5861 feat: Support Docker for ARM architecture(#35) 2025-09-07 16:46:18 +08:00
ShiYu
dffcdb7a8b feat: Add video playback and image preview support to share page 2025-09-07 11:05:10 +08:00
shiyu
19c4394f3d feat: Add queue management functionality to TasksPage 2025-09-06 19:44:00 +08:00
14 changed files with 288 additions and 27 deletions

View File

@@ -42,10 +42,10 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
- name: Build and push Docker image (multi arch)
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ env.DOCKER_TAGS }}

View File

@@ -1,6 +1,6 @@
from fastapi import FastAPI
from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search
from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db
def include_routers(app: FastAPI):
@@ -14,4 +14,5 @@ def include_routers(app: FastAPI):
app.include_router(logs.router)
app.include_router(share.router)
app.include_router(share.public_router)
app.include_router(backup.router)
app.include_router(backup.router)
app.include_router(vector_db.router)

19
api/routes/vector_db.py Normal file
View File

@@ -0,0 +1,19 @@
from fastapi import APIRouter, Depends, HTTPException
from services.auth import get_current_active_user
from models.database import UserAccount
from services.vector_db import VectorDBService
from api.response import success
router = APIRouter(prefix="/api/vector-db", tags=["vector-db"])
@router.post("/clear-all", summary="清空向量数据库")
async def clear_vector_db(user: UserAccount = Depends(get_current_active_user)):
if user.username != 'admin':
raise HTTPException(status_code=403, detail="仅管理员可操作")
try:
service = VectorDBService()
service.clear_all_data()
return success(msg="向量数据库已清空")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1,7 +1,7 @@
services:
foxel:
image: ghcr.io/drizzletime/foxel:latest
#image: ghcr.nju.edu.cn/drizzletime/foxel:latest #国内用户可以用此镜像命令
#image: ghcr.nju.edu.cn/drizzletime/foxel:latest # 国内用户可以用此镜像命令
container_name: foxel
restart: unless-stopped
ports:

View File

@@ -4,7 +4,7 @@ from typing import Any, Optional, Dict
from dotenv import load_dotenv
from models.database import Configuration
load_dotenv(dotenv_path=".env")
VERSION = "v1.1.5"
VERSION = "v1.1.6"
class ConfigCenter:
_cache: Dict[str, Any] = {}

View File

@@ -75,3 +75,9 @@ class VectorDBService:
output_fields=["path"]
)
return [[{'id': r['path'], 'distance': 1.0, 'entity': {'path': r['path']}} for r in results]]
def clear_all_data(self):
"""清空所有集合的内容"""
collections = self.client.list_collections()
for collection_name in collections:
self.client.drop_collection(collection_name)

View File

@@ -14,9 +14,19 @@ export interface AutomationTask {
export type AutomationTaskCreate = Omit<AutomationTask, 'id'>;
export type AutomationTaskUpdate = Partial<AutomationTaskCreate>;
export interface QueuedTask {
id: string;
name: string;
status: 'pending' | 'running' | 'success' | 'failed';
result?: any;
error?: string;
task_info: Record<string, any>;
}
export const tasksApi = {
list: () => request<AutomationTask[]>('/tasks/'),
create: (payload: AutomationTaskCreate) => request<AutomationTask>('/tasks/', { method: 'POST', json: payload }),
update: (id: number, payload: AutomationTaskUpdate) => request<AutomationTask>(`/tasks/${id}`, { method: 'PUT', json: payload }),
remove: (id: number) => request<void>(`/tasks/${id}`, { method: 'DELETE' }),
getQueue: () => request<QueuedTask[]>('/tasks/queue'),
};

5
web/src/api/vectorDB.ts Normal file
View File

@@ -0,0 +1,5 @@
import client from './client';
export const vectorDBApi = {
clearAll: () => client('/vector-db/clear-all', { method: 'POST' }),
};

View File

@@ -1,5 +1,5 @@
import { memo, useState, useEffect, useCallback } from 'react';
import { Card, message, List, Typography, Button, Empty, Breadcrumb } from 'antd';
import { Card, List, Typography, Button, Empty, Breadcrumb } from 'antd';
import { FileOutlined, FolderOutlined, DownloadOutlined } from '@ant-design/icons';
import { shareApi, type ShareInfo } from '../../api/share';
import { type VfsEntry } from '../../api/vfs';
@@ -11,9 +11,10 @@ interface DirectoryViewerProps {
token: string;
shareInfo: ShareInfo;
password?: string;
onFileClick: (entry: VfsEntry, path: string) => void;
}
export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo, password }: DirectoryViewerProps) {
export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo, password, onFileClick }: DirectoryViewerProps) {
const [loading, setLoading] = useState(true);
const [entries, setEntries] = useState<VfsEntry[]>([]);
const [currentPath, setCurrentPath] = useState('/');
@@ -38,11 +39,11 @@ export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo,
}, [loadData, currentPath]);
const handleEntryClick = (entry: VfsEntry) => {
const newPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
if (entry.is_dir) {
const newPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
loadData(newPath);
} else {
message.info('暂不支持预览');
onFileClick(entry, newPath);
}
};

View File

@@ -1,21 +1,27 @@
import { memo, useState, useEffect } from 'react';
import { Card, Spin, Button, Typography, Empty } from 'antd';
import { DownloadOutlined } from '@ant-design/icons';
import { DownloadOutlined, ArrowLeftOutlined } from '@ant-design/icons';
import { shareApi, type ShareInfo } from '../../api/share';
import { type VfsEntry } from '../../api/vfs';
import { format, parseISO } from 'date-fns';
import ReactMarkdown from 'react-markdown';
import { VideoViewer } from './VideoViewer';
const { Title, Text } = Typography;
const isImageViewer = (name: string) => /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(name);
const isVideoViewable = (name: string) => /\.(mp4|webm|ogg|m4v|mov)$/i.test(name);
interface FileViewerProps {
token: string;
shareInfo: ShareInfo;
entry: VfsEntry;
password?: string;
onBack: () => void;
path: string;
}
export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, password }: FileViewerProps) {
export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, password, onBack, path }: FileViewerProps) {
const [loading, setLoading] = useState(true);
const [content, setContent] = useState<string>('');
const [error, setError] = useState('');
@@ -25,7 +31,7 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
setLoading(true);
setError('');
try {
const url = shareApi.downloadUrl(token, entry.name, password);
const url = shareApi.downloadUrl(token, path, password);
const response = await fetch(url);
if (!response.ok) {
throw new Error('无法加载文件');
@@ -44,7 +50,7 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
} else {
setLoading(false);
}
}, [token, entry.name, password]);
}, [token, entry.name, password, path]);
const renderContent = () => {
if (loading) {
@@ -53,9 +59,21 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
if (error) {
return <Empty description={error} />;
}
const downloadUrl = shareApi.downloadUrl(token, path, password);
if (isImageViewer(entry.name)) {
return <img src={downloadUrl} alt={entry.name} style={{ maxWidth: '100%' }} />;
}
if (isVideoViewable(entry.name)) {
return <VideoViewer token={token} entry={entry} password={password} path={path} />;
}
if (entry.name.endsWith('.md')) {
return <ReactMarkdown>{content}</ReactMarkdown>;
}
return (
<Empty
description={
@@ -64,7 +82,7 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
<Button
type="primary"
icon={<DownloadOutlined />}
href={shareApi.downloadUrl(token, entry.name, password)}
href={downloadUrl}
download
>
@@ -84,10 +102,17 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
{shareInfo?.expires_at && `,将于 ${format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd')} 过期`}
</Text>
<div style={{ marginTop: 16 }}>
<Button
style={{ marginBottom: 16, marginRight: 8 }}
icon={<ArrowLeftOutlined />}
onClick={onBack}
>
</Button>
<Button
style={{ marginBottom: 16 }}
icon={<DownloadOutlined />}
href={shareApi.downloadUrl(token, entry.name, password)}
href={shareApi.downloadUrl(token, path, password)}
download
>

View File

@@ -0,0 +1,50 @@
import React, { useEffect, useRef } from 'react';
import Artplayer from 'artplayer';
import { shareApi } from '../../api/share';
import type { VfsEntry } from '../../api/vfs';
interface VideoViewerProps {
token: string;
entry: VfsEntry;
password?: string;
path: string;
}
export const VideoViewer: React.FC<VideoViewerProps> = ({ token, entry, password, path }) => {
const artRef = useRef<HTMLDivElement | null>(null);
const artInstance = useRef<Artplayer | null>(null);
useEffect(() => {
const videoUrl = shareApi.downloadUrl(token, path, password);
if (artRef.current) {
artInstance.current = new Artplayer({
container: artRef.current,
url: videoUrl,
autoplay: true,
fullscreen: true,
fullscreenWeb: true,
pip: true,
setting: true,
playbackRate: true,
});
}
return () => {
if (artInstance.current) {
artInstance.current.destroy();
}
};
}, [token, entry.name, password, path]);
return (
<div
ref={artRef}
style={{
width: '100%',
height: '450px',
backgroundColor: '#000'
}}
/>
);
};

View File

@@ -10,7 +10,7 @@ const PublicSharePage = memo(function PublicSharePage() {
const { token } = useParams<{ token: string }>();
const [loading, setLoading] = useState(true);
const [shareInfo, setShareInfo] = useState<ShareInfo | null>(null);
const [entry, setEntry] = useState<VfsEntry | null>(null);
const [previewFile, setPreviewFile] = useState<{ entry: VfsEntry, path: string } | null>(null);
const [error, setError] = useState('');
const [password, setPassword] = useState('');
const [verified, setVerified] = useState(false);
@@ -37,7 +37,9 @@ const PublicSharePage = memo(function PublicSharePage() {
const listing = await shareApi.listDir(token, '/', currentPassword);
if (listing.entries.length === 1) {
const singleEntry = listing.entries[0];
setEntry(singleEntry);
if (!singleEntry.is_dir) {
setPreviewFile({ entry: singleEntry, path: '/' + singleEntry.name });
}
}
}
@@ -99,11 +101,27 @@ const PublicSharePage = memo(function PublicSharePage() {
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description="无法加载分享信息" /></div>;
}
if (entry && !entry.is_dir) {
return <FileViewer token={token!} shareInfo={shareInfo} entry={entry} password={password} />;
} else {
return <DirectoryViewer token={token!} shareInfo={shareInfo} password={password} />;
const handleFileClick = (entry: VfsEntry, path: string) => {
setPreviewFile({ entry, path });
};
const handleBack = () => {
setPreviewFile(null);
};
if (previewFile) {
return (
<FileViewer
token={token!}
shareInfo={shareInfo}
entry={previewFile.entry}
password={password}
onBack={handleBack}
path={previewFile.path}
/>
);
}
return <DirectoryViewer token={token!} shareInfo={shareInfo} password={password} onFileClick={handleFileClick} />;
});
export default PublicSharePage;

View File

@@ -1,8 +1,9 @@
import { Form, Input, Button, message, Tabs, Space, Card } from 'antd';
import { Form, Input, Button, message, Tabs, Space, Card, Select, Modal } from 'antd';
import { useEffect, useState } from 'react';
import PageCard from '../../components/PageCard';
import { getAllConfig, setConfig } from '../../api/config';
import { AppstoreOutlined, RobotOutlined } from '@ant-design/icons';
import { vectorDBApi } from '../../api/vectorDB';
import { AppstoreOutlined, RobotOutlined, DatabaseOutlined } from '@ant-design/icons';
const APP_CONFIG_KEYS: {key: string, label: string, default?: string}[] = [
{ key: 'APP_NAME', label: '应用名称' },
@@ -134,6 +135,54 @@ export default function SystemSettingsPage() {
</Form>
),
},
{
key: 'vector-db',
label: (
<span>
<DatabaseOutlined style={{ marginRight: 8 }} />
</span>
),
children: (
<Card title="向量数据库设置" style={{ marginTop: 24 }}>
<Form layout="vertical">
<Form.Item label="数据库类型">
<Select
size="large"
value="Milvus Lite"
disabled
options={[{ value: 'Milvus Lite', label: 'Milvus Lite' }]}
/>
</Form.Item>
<Form.Item>
<Button
danger
block
onClick={() => {
Modal.confirm({
title: '确认清空向量数据库?',
content: '此操作将删除所有集合中的所有数据,且不可逆。',
okText: '确认清空',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await vectorDBApi.clearAll();
message.success('向量数据库已清空');
} catch (e: any) {
message.error(e.message || '清空失败');
}
},
});
}}
>
</Button>
</Form.Item>
</Form>
</Card>
),
},
]}
/>
</Space>

View File

@@ -1,7 +1,7 @@
import { memo, useState, useEffect, useCallback } from 'react';
import { Table, Button, Space, Drawer, Form, Input, Switch, message, Typography, Popconfirm, Select } from 'antd';
import { Table, Button, Space, Drawer, Form, Input, Switch, message, Typography, Popconfirm, Select, Modal, Tag } from 'antd';
import PageCard from '../components/PageCard';
import { tasksApi, type AutomationTask } from '../api/tasks';
import { tasksApi, type AutomationTask, type QueuedTask } from '../api/tasks';
import { processorsApi, type ProcessorTypeMeta } from '../api/processors';
import { ProcessorConfigForm } from '../components/ProcessorConfigForm';
@@ -12,6 +12,9 @@ const TasksPage = memo(function TasksPage() {
const [editing, setEditing] = useState<AutomationTask | null>(null);
const [form] = Form.useForm();
const [availableProcessors, setAvailableProcessors] = useState<ProcessorTypeMeta[]>([]);
const [queueModalOpen, setQueueModalOpen] = useState(false);
const [queuedTasks, setQueuedTasks] = useState<QueuedTask[]>([]);
const [queueLoading, setQueueLoading] = useState(false);
const fetchList = useCallback(async () => {
setLoading(true);
@@ -86,11 +89,50 @@ const TasksPage = memo(function TasksPage() {
}
};
const fetchQueue = async () => {
setQueueLoading(true);
try {
const tasks = await tasksApi.getQueue();
setQueuedTasks(tasks);
} catch (e: any) {
message.error(e.message || '加载队列失败');
} finally {
setQueueLoading(false);
}
};
const openQueueModal = () => {
setQueueModalOpen(true);
fetchQueue();
};
const toggleEnabled = async (rec: AutomationTask, enabled: boolean) => {
setEditing(rec);
setLoading(true);
try {
await tasksApi.update(rec.id, { enabled });
message.success('状态已更新');
fetchList();
} catch (e: any) {
message.error(e.message || '更新失败');
} finally {
setEditing(null);
setLoading(false);
}
};
const columns = [
{ title: '名称', dataIndex: 'name' },
{ title: '触发事件', dataIndex: 'event', width: 120 },
{ title: '处理器', dataIndex: 'processor_type', width: 180 },
{ title: '启用', dataIndex: 'enabled', width: 80, render: (v: boolean) => <Switch checked={v} size="small" disabled /> },
{
title: '启用', dataIndex: 'enabled', width: 80, render: (v: boolean, rec: AutomationTask) => <Switch
checked={v}
size="small"
loading={loading && editing?.id === rec.id}
onChange={(checked) => toggleEnabled(rec, checked)}
/>
},
{
title: '操作',
width: 160,
@@ -115,6 +157,7 @@ const TasksPage = memo(function TasksPage() {
extra={
<Space>
<Button onClick={fetchList} loading={loading}></Button>
<Button onClick={openQueueModal}></Button>
<Button type="primary" onClick={openCreate}></Button>
</Space>
}
@@ -174,6 +217,40 @@ const TasksPage = memo(function TasksPage() {
/>
</Form>
</Drawer>
<Modal
title="当前任务队列"
open={queueModalOpen}
onCancel={() => setQueueModalOpen(false)}
width={800}
footer={[
<Button key="refresh" onClick={fetchQueue} loading={queueLoading}></Button>,
<Button key="close" onClick={() => setQueueModalOpen(false)}></Button>
]}
>
<Table
size="small"
rowKey="id"
dataSource={queuedTasks}
loading={queueLoading}
pagination={false}
columns={[
{ title: 'ID', dataIndex: 'id', width: 120, render: (id) => <Typography.Text style={{ fontSize: 12 }} copyable={{ text: id }}>{id.slice(0, 8)}</Typography.Text> },
{ title: '任务名', dataIndex: 'name' },
{ title: '参数', dataIndex: 'task_info', render: (info) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{JSON.stringify(info)}</Typography.Text> },
{
title: '状态', dataIndex: 'status', width: 100, render: (status: QueuedTask['status']) => {
const colorMap = {
pending: 'default',
running: 'processing',
success: 'success',
failed: 'error'
};
return <Tag color={colorMap[status]}>{status}</Tag>;
}
},
]}
/>
</Modal>
</PageCard>
);
});