mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:01:58 +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)
|
||||
|
||||
|
||||
#: 工作流名称最长多少字(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)
|
||||
async def update_workflow(
|
||||
workflow_id: str, payload: WorkflowPatch, session: AsyncSession = Depends(get_session)
|
||||
@@ -212,7 +239,7 @@ async def update_workflow(
|
||||
raise HTTPException(404, _GONE)
|
||||
|
||||
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:
|
||||
workflow.description = payload.description
|
||||
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 { 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 { api } from '../api/client'
|
||||
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 }) {
|
||||
const p = w.published_version
|
||||
// 当前发布版的级别看 published_level(同工具栏的版本标签):画布上改过之后 status 回到草稿,受管发布版照实写「受管」
|
||||
@@ -370,6 +399,11 @@ export function WorkflowPicker({ open, onClose }: { open: boolean; onClose: () =
|
||||
<Plus size={11} /> 以此模板新建
|
||||
</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="复制"
|
||||
className="text-faint group-hover:text-dim"
|
||||
onClick={() => void duplicate(w)} icon={<Copy size={11} />} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
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,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
@@ -13,7 +13,7 @@ import { CanvasDock, type DockTab } from '../canvas/CanvasDock'
|
||||
import { LineageLayer } from '../canvas/LineageLayer'
|
||||
import { VersionsSheet } from '../canvas/VersionsSheet'
|
||||
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 { STUDIO_SHORTCUTS, hintOf, studioShortcut, type StudioShortcutId } from '../canvas/shortcuts'
|
||||
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>
|
||||
<ChevronDown size={12} className="shrink-0 text-faint" />
|
||||
</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} />}
|
||||
{dirty && (
|
||||
|
||||
Reference in New Issue
Block a user