mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
工作流可以改名:画布顶栏名字旁边、工作流列表每一行多一个「重命名」,两处共用 canvas/WorkflowPicker.renameWorkflow——只改名字,不加版本、不碰图,画布上还没保存的改动原样留着(改的正是打开的这一张时只换它的名字)。服务端 PATCH /api/workflows/{id} 的名字以前原样写入:现在去掉首尾空白,空名 422、超过 200 字 422、和别的工作流同名 409;内置模板不许改名(启动时 seed_defaults 按名字补内置模板,改了名下次启动会再导入一份原名的),界面上也不给模板改名入口,要改就「以此模板新建」。测试 tests/test_workflow_rename.py
This commit is contained in:
@@ -203,6 +203,33 @@ async def get_workflow(
|
|||||||
return await _workflow_out(session, workflow)
|
return await _workflow_out(session, workflow)
|
||||||
|
|
||||||
|
|
||||||
|
#: 工作流名称最长多少字(workflows.name 是 String(200))
|
||||||
|
NAME_MAX = 200
|
||||||
|
|
||||||
|
|
||||||
|
async def _checked_name(session: AsyncSession, workflow: Workflow, given: str) -> str:
|
||||||
|
"""改名:去掉首尾空白,不许空、不许超长、不许和别的工作流同名。
|
||||||
|
|
||||||
|
内置模板不许改名:启动时补内置模板是按名字认的(seed.seed_defaults),改了名下次启动会再导入一份原名的。要改名就
|
||||||
|
「以此模板新建」,改新建的那一份。名字没变(只是首尾多了空白)的照常放行。
|
||||||
|
"""
|
||||||
|
name = given.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(422, "工作流名称不能为空")
|
||||||
|
if len(name) > NAME_MAX:
|
||||||
|
raise HTTPException(422, f"工作流名称最多 {NAME_MAX} 个字")
|
||||||
|
if name == workflow.name:
|
||||||
|
return name
|
||||||
|
from app.seed import TEMPLATES
|
||||||
|
|
||||||
|
if workflow.is_template and workflow.name in {t["name"] for t in TEMPLATES}:
|
||||||
|
raise HTTPException(409, "内置模板的名称由系统维护,不能修改:可以「以此模板新建」,再给新建的工作流改名")
|
||||||
|
taken = await session.scalar(select(Workflow.id).where(Workflow.name == name, Workflow.id != workflow.id).limit(1))
|
||||||
|
if taken:
|
||||||
|
raise HTTPException(409, f"已存在同名工作流「{name}」,请使用其他名称")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{workflow_id}", response_model=WorkflowOut)
|
@router.patch("/{workflow_id}", response_model=WorkflowOut)
|
||||||
async def update_workflow(
|
async def update_workflow(
|
||||||
workflow_id: str, payload: WorkflowPatch, session: AsyncSession = Depends(get_session)
|
workflow_id: str, payload: WorkflowPatch, session: AsyncSession = Depends(get_session)
|
||||||
@@ -212,7 +239,7 @@ async def update_workflow(
|
|||||||
raise HTTPException(404, _GONE)
|
raise HTTPException(404, _GONE)
|
||||||
|
|
||||||
if payload.name is not None:
|
if payload.name is not None:
|
||||||
workflow.name = payload.name
|
workflow.name = await _checked_name(session, workflow, payload.name)
|
||||||
if payload.description is not None:
|
if payload.description is not None:
|
||||||
workflow.description = payload.description
|
workflow.description = payload.description
|
||||||
if payload.tags is not None:
|
if payload.tags is not None:
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""工作流改名(PATCH /api/workflows/{id} 的 name):去掉首尾空白,不许空、超长、和别的工作流同名;内置模板不许改名。
|
||||||
|
|
||||||
|
以前名字原样写进去:空名、重名都能存,界面上也没有改名的入口。改名只动名字,不加版本、不碰图。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from app.db.base import SessionLocal
|
||||||
|
from app.db.models import Workflow
|
||||||
|
from app.main import app
|
||||||
|
from app.seed import TEMPLATES
|
||||||
|
|
||||||
|
GRAPH = {"nodes": [{"id": "start", "type": "input", "position": {"x": 0, "y": 0},
|
||||||
|
"data": {"label": "开始", "config": {"fields": [{"name": "question"}]}}}], "edges": []}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
async def _make(client: AsyncClient, name: str) -> dict:
|
||||||
|
r = await client.post("/api/workflows", json={"name": name, "graph": GRAPH})
|
||||||
|
assert r.status_code in (200, 201), r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_workflow_can_be_renamed_without_a_new_version(client):
|
||||||
|
w = await _make(client, "周报草稿-改名测试")
|
||||||
|
r = await client.patch(f"/api/workflows/{w['id']}", json={"name": " 经营周报-改名测试 "})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
got = r.json()
|
||||||
|
assert got["name"] == "经营周报-改名测试", "首尾空白去掉"
|
||||||
|
assert got["version"] == w["version"], "只改名不加版本"
|
||||||
|
assert got["graph"] == w["graph"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_empty_too_long_and_taken_names_are_refused(client):
|
||||||
|
a = await _make(client, "甲-改名测试")
|
||||||
|
await _make(client, "乙-改名测试")
|
||||||
|
r = await client.patch(f"/api/workflows/{a['id']}", json={"name": " "})
|
||||||
|
assert r.status_code == 422 and "不能为空" in r.json()["detail"]
|
||||||
|
r = await client.patch(f"/api/workflows/{a['id']}", json={"name": "长" * 201})
|
||||||
|
assert r.status_code == 422 and "最多" in r.json()["detail"]
|
||||||
|
r = await client.patch(f"/api/workflows/{a['id']}", json={"name": "乙-改名测试"})
|
||||||
|
assert r.status_code == 409 and "同名" in r.json()["detail"]
|
||||||
|
r = await client.patch(f"/api/workflows/{a['id']}", json={"name": "甲-改名测试 "})
|
||||||
|
assert r.status_code == 200, "名字没变(只多了空白)照常放行,不算和自己重名"
|
||||||
|
async with SessionLocal() as s:
|
||||||
|
assert (await s.get(Workflow, a["id"])).name == "甲-改名测试"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_built_in_template_keeps_its_name(client):
|
||||||
|
builtin = TEMPLATES[0]["name"]
|
||||||
|
async with SessionLocal() as s:
|
||||||
|
row = Workflow(name=builtin, description="", graph=GRAPH, tags=["template"], is_template=True)
|
||||||
|
s.add(row)
|
||||||
|
await s.commit()
|
||||||
|
wid = row.id
|
||||||
|
r = await client.patch(f"/api/workflows/{wid}", json={"name": "我的模板-改名测试"})
|
||||||
|
assert r.status_code == 409 and "内置模板" in r.json()["detail"], "启动时按名字补内置模板,改了名会再导入一份"
|
||||||
|
r = await client.patch(f"/api/workflows/{wid}", json={"description": "只改说明可以"})
|
||||||
|
assert r.status_code == 200 and r.json()["name"] == builtin
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Check, Copy, Layers, LayoutTemplate, Plus, Search, Trash2, Workflow as WorkflowIcon } from 'lucide-react'
|
import { Check, Copy, Layers, LayoutTemplate, Pencil, Plus, Search, Trash2, Workflow as WorkflowIcon } from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import { useCatalog } from '../store/catalog'
|
import { useCatalog } from '../store/catalog'
|
||||||
@@ -156,6 +156,35 @@ export async function createWorkflow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 给工作流改名。只改名字:不加版本、不碰图,画布上还没保存的改动原样留着(改的正是画布上打开的这一张时,只换它的名字)。
|
||||||
|
* 同名、空名在这里先拦一道,服务端也拦(api/workflows._checked_name);内置模板不给改名入口(启动时按名字补内置模板,
|
||||||
|
* 服务端会拒),要改就「以此模板新建」。
|
||||||
|
*/
|
||||||
|
export async function renameWorkflow(w: Workflow, refresh: () => Promise<void> | void): Promise<Workflow | null> {
|
||||||
|
const names = new Set(useCatalog.getState().workflows.filter((x) => x.id !== w.id).map((x) => x.name))
|
||||||
|
const raw = await promptDialog({
|
||||||
|
title: `重命名「${w.name}」`,
|
||||||
|
label: '名称',
|
||||||
|
initial: w.name,
|
||||||
|
confirmLabel: '重命名',
|
||||||
|
validate: (v) => (!v.trim() ? '名称不能为空' : names.has(v.trim()) ? '已存在同名工作流,请使用其他名称' : null),
|
||||||
|
})
|
||||||
|
const name = raw?.trim()
|
||||||
|
if (!name || name === w.name) return null
|
||||||
|
try {
|
||||||
|
const updated = await api.workflows.update(w.id, { name })
|
||||||
|
const open = useStudio.getState().workflow
|
||||||
|
if (open?.id === w.id) useStudio.setState({ workflow: { ...open, name: updated.name } })
|
||||||
|
void refresh()
|
||||||
|
toast.ok(`已重命名为「${updated.name}」`)
|
||||||
|
return updated
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function StatusChip({ w }: { w: Workflow }) {
|
function StatusChip({ w }: { w: Workflow }) {
|
||||||
const p = w.published_version
|
const p = w.published_version
|
||||||
// 当前发布版的级别看 published_level(同工具栏的版本标签):画布上改过之后 status 回到草稿,受管发布版照实写「受管」
|
// 当前发布版的级别看 published_level(同工具栏的版本标签):画布上改过之后 status 回到草稿,受管发布版照实写「受管」
|
||||||
@@ -370,6 +399,11 @@ export function WorkflowPicker({ open, onClose }: { open: boolean; onClose: () =
|
|||||||
<Plus size={11} /> 以此模板新建
|
<Plus size={11} /> 以此模板新建
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!w.is_template && (
|
||||||
|
<IconButton label={`重命名「${w.name}」`} title="重命名"
|
||||||
|
className="text-faint group-hover:text-dim"
|
||||||
|
onClick={() => void renameWorkflow(w, refresh)} icon={<Pencil size={11} />} />
|
||||||
|
)}
|
||||||
<IconButton label={`复制「${w.name}」`} title="复制"
|
<IconButton label={`复制「${w.name}」`} title="复制"
|
||||||
className="text-faint group-hover:text-dim"
|
className="text-faint group-hover:text-dim"
|
||||||
onClick={() => void duplicate(w)} icon={<Copy size={11} />} />
|
onClick={() => void duplicate(w)} icon={<Copy size={11} />} />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'
|
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
AlertTriangle, Check, ChevronDown, History, LayoutGrid, MoreHorizontal, PanelRightClose, PanelRightOpen, Plus,
|
AlertTriangle, Check, ChevronDown, History, LayoutGrid, MoreHorizontal, PanelRightClose, PanelRightOpen, Pencil, Plus,
|
||||||
Redo2, RotateCw, Save, ShieldCheck, Square, Undo2, Variable, Wand2, XCircle,
|
Redo2, RotateCw, Save, ShieldCheck, Square, Undo2, Variable, Wand2, XCircle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@@ -13,7 +13,7 @@ import { CanvasDock, type DockTab } from '../canvas/CanvasDock'
|
|||||||
import { LineageLayer } from '../canvas/LineageLayer'
|
import { LineageLayer } from '../canvas/LineageLayer'
|
||||||
import { VersionsSheet } from '../canvas/VersionsSheet'
|
import { VersionsSheet } from '../canvas/VersionsSheet'
|
||||||
import { PublishDialog } from '../canvas/PublishDialog'
|
import { PublishDialog } from '../canvas/PublishDialog'
|
||||||
import { WorkflowPicker, confirmDiscard, createWorkflow, takeDiscarded } from '../canvas/WorkflowPicker'
|
import { WorkflowPicker, confirmDiscard, createWorkflow, renameWorkflow, takeDiscarded } from '../canvas/WorkflowPicker'
|
||||||
import { fieldOfIssue, problemsOf, type Problem } from '../canvas/issues'
|
import { fieldOfIssue, problemsOf, type Problem } from '../canvas/issues'
|
||||||
import { STUDIO_SHORTCUTS, hintOf, studioShortcut, type StudioShortcutId } from '../canvas/shortcuts'
|
import { STUDIO_SHORTCUTS, hintOf, studioShortcut, type StudioShortcutId } from '../canvas/shortcuts'
|
||||||
import { AssistantPanel } from '../run/AssistantPanel'
|
import { AssistantPanel } from '../run/AssistantPanel'
|
||||||
@@ -527,6 +527,11 @@ export function StudioPage() {
|
|||||||
<span className="max-w-56 truncate font-medium @max-[1120px]:max-w-32">{workflow?.name ?? '选择工作流'}</span>
|
<span className="max-w-56 truncate font-medium @max-[1120px]:max-w-32">{workflow?.name ?? '选择工作流'}</span>
|
||||||
<ChevronDown size={12} className="shrink-0 text-faint" />
|
<ChevronDown size={12} className="shrink-0 text-faint" />
|
||||||
</button>
|
</button>
|
||||||
|
{/* 改名只改名字,画布上没保存的改动不受影响;内置模板不给改名(见 renameWorkflow) */}
|
||||||
|
{workflow && !workflow.is_template && (
|
||||||
|
<IconButton label={`重命名「${workflow.name}」`} title="重命名" className="-ml-1 shrink-0 text-faint hover:text-dim"
|
||||||
|
onClick={() => void renameWorkflow(workflow, refresh)} icon={<Pencil size={12} />} />
|
||||||
|
)}
|
||||||
|
|
||||||
{workflow && <VersionLabel workflow={workflow} />}
|
{workflow && <VersionLabel workflow={workflow} />}
|
||||||
{dirty && (
|
{dirty && (
|
||||||
|
|||||||
Reference in New Issue
Block a user