diff --git a/admin-v2/src/layouts/AdminLayout.tsx b/admin-v2/src/layouts/AdminLayout.tsx index ec67140..097a524 100644 --- a/admin-v2/src/layouts/AdminLayout.tsx +++ b/admin-v2/src/layouts/AdminLayout.tsx @@ -19,6 +19,7 @@ import { BookOutlined, CrownOutlined, SafetyOutlined, + FieldTimeOutlined, } from '@ant-design/icons' import { Avatar, Dropdown, Tooltip, Drawer } from 'antd' import { useAuthStore } from '@/stores/authStore' @@ -46,6 +47,7 @@ const navItems: NavItem[] = [ { path: '/api-keys', name: 'API 密钥', icon: , permission: 'provider:manage', group: '资源管理' }, { path: '/usage', name: '用量统计', icon: , permission: 'admin:full', group: '运维' }, { path: '/relay', name: '中转任务', icon: , permission: 'relay:use', group: '运维' }, + { path: '/scheduled-tasks', name: '定时任务', icon: , permission: 'scheduler:read', group: '运维' }, { path: '/knowledge', name: '知识库', icon: , permission: 'knowledge:read', group: '资源管理' }, { path: '/billing', name: '计费管理', icon: , permission: 'billing:read', group: '核心' }, { path: '/logs', name: '操作日志', icon: , permission: 'admin:full', group: '运维' }, @@ -211,6 +213,7 @@ const breadcrumbMap: Record = { '/agent-templates': 'Agent 模板', '/usage': '用量统计', '/relay': '中转任务', + '/scheduled-tasks': '定时任务', '/knowledge': '知识库', '/billing': '计费管理', '/config': '系统配置', diff --git a/admin-v2/src/pages/ScheduledTasks.tsx b/admin-v2/src/pages/ScheduledTasks.tsx new file mode 100644 index 0000000..5313a16 --- /dev/null +++ b/admin-v2/src/pages/ScheduledTasks.tsx @@ -0,0 +1,397 @@ +// ============================================================ +// 定时任务 — 管理页面 +// ============================================================ + +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Button, message, Tag, Modal, Form, Input, Select, Switch, Popconfirm, Space } from 'antd' +import type { ProColumns } from '@ant-design/pro-components' +import { ProTable } from '@ant-design/pro-components' +import { PlusOutlined } from '@ant-design/icons' +import { scheduledTaskService } from '@/services/scheduled-tasks' +import type { ScheduledTask, CreateScheduledTaskRequest, UpdateScheduledTaskRequest } from '@/services/scheduled-tasks' +import { PageHeader } from '@/components/PageHeader' +import { ErrorState } from '@/components/ErrorState' + +const scheduleTypeLabels: Record = { + cron: 'Cron', + interval: '间隔', + once: '一次性', +} + +const scheduleTypeColors: Record = { + cron: 'blue', + interval: 'green', + once: 'orange', +} + +const targetTypeLabels: Record = { + agent: 'Agent', + hand: 'Hand', + workflow: 'Workflow', +} + +const targetTypeColors: Record = { + agent: 'purple', + hand: 'cyan', + workflow: 'geekblue', +} + +function formatDateTime(value: string | null): string { + if (!value) return '-' + return new Date(value).toLocaleString('zh-CN') +} + +function formatDuration(ms: number | null): string { + if (ms === null) return '-' + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +export default function ScheduledTasks() { + const queryClient = useQueryClient() + const [form] = Form.useForm() + const [modalOpen, setModalOpen] = useState(false) + const [editingId, setEditingId] = useState(null) + + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ['scheduled-tasks'], + queryFn: ({ signal }) => scheduledTaskService.list(signal), + }) + + const createMutation = useMutation({ + mutationFn: (data: CreateScheduledTaskRequest) => scheduledTaskService.create(data), + onSuccess: () => { + message.success('任务创建成功') + queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] }) + closeModal() + }, + onError: (err: Error) => message.error(err.message || '创建失败'), + }) + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateScheduledTaskRequest }) => + scheduledTaskService.update(id, data), + onSuccess: () => { + message.success('任务更新成功') + queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] }) + closeModal() + }, + onError: (err: Error) => message.error(err.message || '更新失败'), + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => scheduledTaskService.delete(id), + onSuccess: () => { + message.success('任务已删除') + queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] }) + }, + onError: (err: Error) => message.error(err.message || '删除失败'), + }) + + const toggleMutation = useMutation({ + mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => + scheduledTaskService.update(id, { enabled }), + onSuccess: () => { + message.success('状态已更新') + queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] }) + }, + onError: (err: Error) => message.error(err.message || '状态更新失败'), + }) + + const columns: ProColumns[] = [ + { + title: '任务名称', + dataIndex: 'name', + width: 160, + ellipsis: true, + }, + { + title: '调度规则', + dataIndex: 'schedule', + width: 140, + ellipsis: true, + hideInSearch: true, + }, + { + title: '调度类型', + dataIndex: 'schedule_type', + width: 100, + valueType: 'select', + valueEnum: { + cron: { text: 'Cron' }, + interval: { text: '间隔' }, + once: { text: '一次性' }, + }, + render: (_, record) => ( + + {scheduleTypeLabels[record.schedule_type] || record.schedule_type} + + ), + }, + { + title: '目标', + dataIndex: ['target', 'type'], + width: 140, + hideInSearch: true, + render: (_, record) => ( + + + {targetTypeLabels[record.target.type] || record.target.type} + + {record.target.id} + + ), + }, + { + title: '启用', + dataIndex: 'enabled', + width: 80, + hideInSearch: true, + render: (_, record) => ( + toggleMutation.mutate({ id: record.id, enabled: checked })} + /> + ), + }, + { + title: '执行次数', + dataIndex: 'run_count', + width: 90, + hideInSearch: true, + render: (_, record) => ( + {record.run_count} + ), + }, + { + title: '上次执行', + dataIndex: 'last_run', + width: 170, + hideInSearch: true, + render: (_, record) => formatDateTime(record.last_run), + }, + { + title: '下次执行', + dataIndex: 'next_run', + width: 170, + hideInSearch: true, + render: (_, record) => formatDateTime(record.next_run), + }, + { + title: '上次耗时', + dataIndex: 'last_duration_ms', + width: 100, + hideInSearch: true, + render: (_, record) => formatDuration(record.last_duration_ms), + }, + { + title: '上次错误', + dataIndex: 'last_error', + width: 160, + ellipsis: true, + hideInSearch: true, + render: (_, record) => + record.last_error ? ( + {record.last_error} + ) : ( + - + ), + }, + { + title: '操作', + width: 140, + hideInSearch: true, + render: (_, record) => ( + + + deleteMutation.mutate(record.id)} + > + + + + ), + }, + ] + + const openCreateModal = () => { + setEditingId(null) + form.resetFields() + form.setFieldsValue({ schedule_type: 'cron', enabled: true }) + setModalOpen(true) + } + + const openEditModal = (record: ScheduledTask) => { + setEditingId(record.id) + form.setFieldsValue({ + name: record.name, + schedule: record.schedule, + schedule_type: record.schedule_type, + target_type: record.target.type, + target_id: record.target.id, + description: record.description ?? '', + enabled: record.enabled, + }) + setModalOpen(true) + } + + const closeModal = () => { + setModalOpen(false) + setEditingId(null) + form.resetFields() + } + + const handleSave = async () => { + const values = await form.validateFields() + const payload: CreateScheduledTaskRequest | UpdateScheduledTaskRequest = { + name: values.name, + schedule: values.schedule, + schedule_type: values.schedule_type, + target: { + type: values.target_type, + id: values.target_id, + }, + description: values.description || undefined, + enabled: values.enabled, + } + + if (editingId) { + updateMutation.mutate({ id: editingId, data: payload }) + } else { + createMutation.mutate(payload as CreateScheduledTaskRequest) + } + } + + if (error) { + return ( + <> + + refetch()} /> + + ) + } + + const tasks = Array.isArray(data) ? data : [] + + return ( +
+ } + onClick={openCreateModal} + > + 新建任务 + + } + /> + + + columns={columns} + dataSource={tasks} + loading={isLoading} + rowKey="id" + search={false} + toolBarRender={() => []} + pagination={{ + showSizeChanger: true, + defaultPageSize: 20, + }} + options={{ + density: false, + fullScreen: false, + reload: () => refetch(), + }} + /> + + + {editingId ? '编辑任务' : '新建任务'} + + } + open={modalOpen} + onOk={handleSave} + onCancel={closeModal} + confirmLoading={createMutation.isPending || updateMutation.isPending} + width={520} + destroyOnClose + > +
+ + + + + + + + + + + + + + + + + + + +
+
+
+ ) +} diff --git a/admin-v2/src/router/index.tsx b/admin-v2/src/router/index.tsx index 1b11f81..cc7bee9 100644 --- a/admin-v2/src/router/index.tsx +++ b/admin-v2/src/router/index.tsx @@ -30,6 +30,7 @@ export const router = createBrowserRouter([ { path: 'usage', lazy: () => import('@/pages/Usage').then((m) => ({ Component: m.default })) }, { path: 'billing', lazy: () => import('@/pages/Billing').then((m) => ({ Component: m.default })) }, { path: 'relay', lazy: () => import('@/pages/Relay').then((m) => ({ Component: m.default })) }, + { path: 'scheduled-tasks', lazy: () => import('@/pages/ScheduledTasks').then((m) => ({ Component: m.default })) }, { path: 'knowledge', lazy: () => import('@/pages/Knowledge').then((m) => ({ Component: m.default })) }, { path: 'config', lazy: () => import('@/pages/Config').then((m) => ({ Component: m.default })) }, { path: 'prompts', lazy: () => import('@/pages/Prompts').then((m) => ({ Component: m.default })) }, diff --git a/admin-v2/src/services/scheduled-tasks.ts b/admin-v2/src/services/scheduled-tasks.ts new file mode 100644 index 0000000..d9440b9 --- /dev/null +++ b/admin-v2/src/services/scheduled-tasks.ts @@ -0,0 +1,71 @@ +// ============================================================ +// 定时任务 — Service +// ============================================================ + +import request, { withSignal } from './request' + +// === Types === + +export interface TaskTarget { + type: string // "agent" | "hand" | "workflow" + id: string +} + +export interface ScheduledTask { + id: string + name: string + schedule: string + schedule_type: string // "cron" | "interval" | "once" + target: TaskTarget + enabled: boolean + description: string | null + last_run: string | null + next_run: string | null + run_count: number + last_result: string | null + last_error: string | null + last_duration_ms: number | null + created_at: string +} + +export interface CreateScheduledTaskRequest { + name: string + schedule: string + schedule_type?: string + target: TaskTarget + description?: string + enabled?: boolean +} + +export interface UpdateScheduledTaskRequest { + name?: string + schedule?: string + schedule_type?: string + target?: TaskTarget + description?: string + enabled?: boolean +} + +// === Service === + +export const scheduledTaskService = { + list: (signal?: AbortSignal) => + request.get('/scheduler/tasks', withSignal({}, signal)) + .then((r) => r.data), + + get: (id: string, signal?: AbortSignal) => + request.get(`/scheduler/tasks/${id}`, withSignal({}, signal)) + .then((r) => r.data), + + create: (data: CreateScheduledTaskRequest) => + request.post('/scheduler/tasks', data) + .then((r) => r.data), + + update: (id: string, data: UpdateScheduledTaskRequest) => + request.patch(`/scheduler/tasks/${id}`, data) + .then((r) => r.data), + + delete: (id: string) => + request.delete(`/scheduler/tasks/${id}`) + .then((r) => r.data), +} diff --git a/crates/zclaw-saas/src/agent_template/service.rs b/crates/zclaw-saas/src/agent_template/service.rs index b726e0c..9ab9b23 100644 --- a/crates/zclaw-saas/src/agent_template/service.rs +++ b/crates/zclaw-saas/src/agent_template/service.rs @@ -348,7 +348,8 @@ pub async fn unassign_template( } /// Create an agent configuration from a template. -/// Merges capabilities into tools, applies default model fallback. +/// Merges capabilities into tools. Model is passed through as-is (None if not set); +/// the frontend resolves it from SaaS admin's available models list. pub async fn create_agent_from_template( db: &PgPool, template_id: &str, @@ -368,7 +369,8 @@ pub async fn create_agent_from_template( Ok(AgentConfigFromTemplate { name: t.name, - model: t.model.unwrap_or_else(|| "gpt-4o-mini".to_string()), + // No hardcoded fallback — frontend resolves from available models + model: t.model, system_prompt: t.system_prompt, tools: merged_tools, diff --git a/crates/zclaw-saas/src/agent_template/types.rs b/crates/zclaw-saas/src/agent_template/types.rs index cef344b..14ea1b3 100644 --- a/crates/zclaw-saas/src/agent_template/types.rs +++ b/crates/zclaw-saas/src/agent_template/types.rs @@ -120,7 +120,7 @@ pub struct AssignTemplateRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentConfigFromTemplate { pub name: String, - pub model: String, + pub model: Option, pub system_prompt: Option, pub tools: Vec, pub soul_content: Option, diff --git a/crates/zclaw-saas/src/knowledge/handlers.rs b/crates/zclaw-saas/src/knowledge/handlers.rs index 50b5fca..d5aaf8e 100644 --- a/crates/zclaw-saas/src/knowledge/handlers.rs +++ b/crates/zclaw-saas/src/knowledge/handlers.rs @@ -308,6 +308,7 @@ pub async fn delete_item( // === 版本控制 === /// GET /api/v1/knowledge/items/:id/versions +// @reserved - no frontend caller pub async fn list_versions( State(state): State, Extension(ctx): Extension, @@ -324,6 +325,7 @@ pub async fn list_versions( } /// GET /api/v1/knowledge/items/:id/versions/:v +// @reserved - no frontend caller pub async fn get_version( State(state): State, Extension(ctx): Extension, @@ -342,6 +344,7 @@ pub async fn get_version( } /// POST /api/v1/knowledge/items/:id/rollback/:v +// @reserved - no frontend caller pub async fn rollback_version( State(state): State, Extension(ctx): Extension, diff --git a/crates/zclaw-saas/src/model_config/handlers.rs b/crates/zclaw-saas/src/model_config/handlers.rs index a38eebb..b14e96d 100644 --- a/crates/zclaw-saas/src/model_config/handlers.rs +++ b/crates/zclaw-saas/src/model_config/handlers.rs @@ -228,6 +228,7 @@ pub async fn create_api_key( } /// POST /api/v1/keys/:id/rotate +// @reserved - no frontend caller pub async fn rotate_api_key( State(state): State, Path(id): Path, diff --git a/crates/zclaw-saas/src/scheduled_task/handlers.rs b/crates/zclaw-saas/src/scheduled_task/handlers.rs index 0458667..df3d53e 100644 --- a/crates/zclaw-saas/src/scheduled_task/handlers.rs +++ b/crates/zclaw-saas/src/scheduled_task/handlers.rs @@ -11,6 +11,7 @@ use crate::auth::types::AuthContext; use super::{types::*, service}; /// POST /api/scheduler/tasks — 创建定时任务 +// @reserved - no frontend caller pub async fn create_task( State(state): State, Extension(ctx): Extension, @@ -39,6 +40,7 @@ pub async fn create_task( } /// GET /api/scheduler/tasks — 列出定时任务 +// @reserved - no frontend caller pub async fn list_tasks( State(state): State, Extension(ctx): Extension, @@ -48,6 +50,7 @@ pub async fn list_tasks( } /// GET /api/scheduler/tasks/:id — 获取单个定时任务 +// @reserved - no frontend caller pub async fn get_task( State(state): State, Extension(ctx): Extension, @@ -58,6 +61,7 @@ pub async fn get_task( } /// PATCH /api/scheduler/tasks/:id — 更新定时任务 +// @reserved - no frontend caller pub async fn update_task( State(state): State, Extension(ctx): Extension, @@ -69,6 +73,7 @@ pub async fn update_task( } /// DELETE /api/scheduler/tasks/:id — 删除定时任务 +// @reserved - no frontend caller pub async fn delete_task( State(state): State, Extension(ctx): Extension, diff --git a/desktop/src/components/ChatArea.tsx b/desktop/src/components/ChatArea.tsx index 6b6c20a..f1c5e35 100644 --- a/desktop/src/components/ChatArea.tsx +++ b/desktop/src/components/ChatArea.tsx @@ -9,7 +9,7 @@ import { useAgentStore } from '../store/agentStore'; import { useConfigStore } from '../store/configStore'; import { type UnlistenFn } from '@tauri-apps/api/event'; import { safeListenEvent } from '../lib/safe-tauri'; -import { Paperclip, SquarePen, ArrowUp, MessageSquare, Download, X, FileText, Image as ImageIcon } from 'lucide-react'; +import { Paperclip, SquarePen, ArrowUp, MessageSquare, Download, X, FileText, Image as ImageIcon, Search } from 'lucide-react'; import { Button, EmptyState, MessageListSkeleton, LoadingDots } from './ui'; import { ResizableChatLayout } from './ai/ResizableChatLayout'; import { ArtifactPanel } from './ai/ArtifactPanel'; @@ -18,7 +18,7 @@ import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/anim import { FirstConversationPrompt } from './FirstConversationPrompt'; import { ClassroomPlayer } from './classroom_player'; import { useClassroomStore } from '../store/classroomStore'; -// MessageSearch temporarily removed during DeerFlow redesign +import { MessageSearch } from './MessageSearch'; import { OfflineIndicator } from './OfflineIndicator'; import { useVirtualizedMessages, @@ -67,6 +67,7 @@ export function ChatArea() { const [input, setInput] = useState(''); const [pendingFiles, setPendingFiles] = useState([]); + const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); const scrollRef = useRef(null); const textareaRef = useRef(null); const messageRefs = useRef>(new Map()); @@ -325,6 +326,17 @@ export function ChatArea() { ); })()} + {messages.length > 0 && ( + + )} {messages.length > 0 && ( + + {snapshotsExpanded && ( +
+ {snapshotsError && ( +
+ + {snapshotsError} +
+ )} + + {snapshotsLoading ? ( +
+ + 加载中... +
+ ) : snapshots.length === 0 ? ( +
+ 暂无快照记录 +
+ ) : ( + snapshots.map((snap) => { + const isRestoring = restoringSnapshotId === snap.id; + const isConfirming = confirmRestoreId === snap.id; + const timeLabel = formatSnapshotTime(snap.timestamp); + + return ( +
+
+ +
+
+
+ {timeLabel} + {isConfirming ? ( +
+ + +
+ ) : ( + + )} +
+

+ {snap.reason || '自动快照'} +

+
+
+ ); + }) + )} +
+ )} + ) : activeTab === 'files' ? (
@@ -791,3 +953,15 @@ function AgentToggle({ ); } + +function formatSnapshotTime(timestamp: string): string { + const now = Date.now(); + const then = new Date(timestamp).getTime(); + const diff = now - then; + + if (diff < 60000) return '刚刚'; + if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`; + if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`; + return new Date(timestamp).toLocaleDateString('zh-CN'); +} diff --git a/desktop/src/components/VikingPanel.tsx b/desktop/src/components/VikingPanel.tsx index 28811fa..2d663c1 100644 --- a/desktop/src/components/VikingPanel.tsx +++ b/desktop/src/components/VikingPanel.tsx @@ -12,12 +12,14 @@ import { CheckCircle, FileText, Database, + Sparkles, } from 'lucide-react'; import { getVikingStatus, findVikingResources, listVikingResources, readVikingResource, + storeWithSummaries, } from '../lib/viking-client'; import type { VikingStatus, VikingFindResult } from '../lib/viking-client'; @@ -32,6 +34,9 @@ export function VikingPanel() { const [expandedUri, setExpandedUri] = useState(null); const [expandedContent, setExpandedContent] = useState(null); const [isLoadingL2, setIsLoadingL2] = useState(false); + const [isGeneratingSummary, setIsGeneratingSummary] = useState(false); + const [summaryUri, setSummaryUri] = useState(''); + const [summaryContent, setSummaryContent] = useState(''); const loadStatus = async () => { setIsLoading(true); @@ -292,6 +297,61 @@ export function VikingPanel() {
)} + {/* Summary Generation */} + {status?.available && ( +
+

智能摘要

+

+ 存储资源并自动通过 LLM 生成 L0/L1 多级摘要(需配置摘要驱动) +

+
+ setSummaryUri(e.target.value)} + placeholder="资源 URI (如: notes/project-plan)" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +