diff --git a/docs/deployment.md b/docs/deployment.md index 2baa8b1..bd472e1 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -49,6 +49,8 @@ The platform listens on `LOOPFORGE_BIND`, `0.0.0.0` by default, without promptin ### Docker sandboxes +The platform exposes a built-in default sandbox (`local`) even when the catalog SQL has not been installed. It is an in-memory fallback and does not insert database rows. An existing saved `local` profile takes priority, including its configured driver and environment. The default cannot be deleted; edit its configuration or create another profile to change how tasks run. The full installer still imports starter SOPs and the welcome project. + For native services running as an ordinary host user, default/root Docker images run with that service user's UID/GID so private workspaces remain accessible without relaxing their permissions or adding capabilities. Images that explicitly select a non-root user retain that identity; operators must arrange compatible bind-mount ownership for those images. The Compose deployment mounts the host Docker socket into the platform container so built-in AI SOPs run out of the box. `AGENTFLOW_ISOLATED_IMAGE` points to the installed platform image: default sandboxes start separate sibling containers from that same image, with no second CLI download or image build. They do not mount the Docker socket. Custom Dockerfiles still build and cache their own images. The run workspace is mounted at `/workspace` on the Compose network. Because bind mounts are resolved by the host Docker daemon, `LOOPFORGE_DATA_DIR` (written to `.env` by `scripts/configure.sh`, default `./.data`) is mounted into the platform container at the same absolute path; workspaces and CLI state live under it. diff --git a/internal/httpapi/catalog.go b/internal/httpapi/catalog.go index 596f16c..ad83343 100644 --- a/internal/httpapi/catalog.go +++ b/internal/httpapi/catalog.go @@ -855,7 +855,7 @@ func filterEnvironmentAssets[T any](items []T, projectID string, scope func(T) ( out := make([]T, 0, len(items)) for _, item := range items { owner, shared := scope(item) - if owner == projectID || (owner != "" && shared) { + if owner == projectID || shared { out = append(out, item) } } diff --git a/internal/httpapi/default_sandbox_test.go b/internal/httpapi/default_sandbox_test.go new file mode 100644 index 0000000..dad480d --- /dev/null +++ b/internal/httpapi/default_sandbox_test.go @@ -0,0 +1,64 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "reflect" + "testing" + + "agentflow/internal/domain" +) + +func TestProjectEnvironmentAssetsIncludeSharedGlobalDefaults(t *testing.T) { + profiles := []domain.SandboxProfile{ + {ID: "own", ProjectID: "project"}, + {ID: "foreign-shared", ProjectID: "other", Shared: true}, + {ID: "foreign-private", ProjectID: "other"}, + {ID: "global-shared", Shared: true}, + {ID: "global-private"}, + } + visible := filterEnvironmentAssets(profiles, "project", func(profile domain.SandboxProfile) (string, bool) { + return profile.ProjectID, profile.Shared + }) + var ids []string + for _, profile := range visible { + ids = append(ids, profile.ID) + } + if !reflect.DeepEqual(ids, []string{"own", "foreign-shared", "global-shared"}) { + t.Fatalf("incorrect project visibility: %v", ids) + } +} + +func TestDefaultSandboxWithoutCatalogRowsSupportsSOPCreation(t *testing.T) { + f := cvBNewHTTPFixture(t) + if err := f.s.DB.Exec("DELETE FROM sandboxes").Error; err != nil { + t.Fatal(err) + } + const projectID = "builtin-default-project" + if err := f.s.PutProject(domain.Project{ID: projectID, Name: "Default sandbox check", OwnerID: f.user.ID}); err != nil { + t.Fatal(err) + } + bootstrap := cvBRequest(t, f, http.MethodGet, "/api/bootstrap", nil, true) + var catalog struct { + Sandboxes []domain.SandboxProfile `json:"sandboxes"` + } + if bootstrap.Code != http.StatusOK || json.Unmarshal(bootstrap.Body.Bytes(), &catalog) != nil || len(catalog.Sandboxes) != 1 || catalog.Sandboxes[0].ID != "local" { + t.Fatalf("bootstrap does not expose the default: %d %s", bootstrap.Code, bootstrap.Body.String()) + } + listed := cvBRequest(t, f, http.MethodGet, "/api/sandboxes?projectId="+projectID, nil, true) + var profiles []domain.SandboxProfile + if listed.Code != http.StatusOK || json.Unmarshal(listed.Body.Bytes(), &profiles) != nil || len(profiles) != 1 || profiles[0].ID != "local" { + t.Fatalf("project cannot select the default: %d %s", listed.Code, listed.Body.String()) + } + created := cvBRequest(t, f, http.MethodPut, "/api/workflows", map[string]any{ + "id": "builtin-default-sop", "name": "Blank SOP", "projectId": projectID, + "status": "draft", "sandboxProfileId": "local", "nodes": []any{}, "edges": []any{}, + }, true) + if created.Code != http.StatusOK { + t.Fatalf("blank SOP creation failed: %d %s", created.Code, created.Body.String()) + } + var count int64 + if err := f.s.DB.Table("sandboxes").Count(&count).Error; err != nil || count != 0 { + t.Fatalf("API requests seeded rows: rows=%d err=%v", count, err) + } +} diff --git a/internal/repo/catalog.go b/internal/repo/catalog.go index f847e54..7e96c2b 100644 --- a/internal/repo/catalog.go +++ b/internal/repo/catalog.go @@ -318,11 +318,31 @@ func (r *CatalogRepo) DeleteEnvironment(id string) error { } func (r *CatalogRepo) Sandboxes() []domain.SandboxProfile { - return payloadList[domain.SandboxProfile](r.DB, "sandboxes", "") + profiles := payloadList[domain.SandboxProfile](r.DB, "sandboxes", "") + for _, profile := range profiles { + if profile.ID == DefaultSandboxProfileID { + return profiles + } + } + if profile, ok := r.Sandbox(DefaultSandboxProfileID); ok { + profiles = append(profiles, profile) + } + return profiles } func (r *CatalogRepo) Sandbox(id string) (domain.SandboxProfile, bool) { - return payloadOne[domain.SandboxProfile](r.DB, "sandboxes", id) + var profile domain.SandboxProfile + var row payloadRow + err := r.DB.Table("sandboxes").Where("id = ?", id).Take(&row).Error + // A missing row may use the built-in default. Database failures or invalid + // stored configuration must not silently switch a run's execution policy. + if errors.Is(err, gorm.ErrRecordNotFound) && id == DefaultSandboxProfileID { + return DefaultSandboxProfile(), true + } + if err != nil || json.Unmarshal(row.Payload, &profile) != nil { + return domain.SandboxProfile{}, false + } + return profile, true } func (r *CatalogRepo) PutSandbox(v domain.SandboxProfile) error { @@ -357,6 +377,9 @@ func (r *CatalogRepo) PutSandbox(v domain.SandboxProfile) error { } func (r *CatalogRepo) DeleteSandbox(id string) error { + if id == DefaultSandboxProfileID { + return errors.New("默认运行沙箱不能删除;可编辑配置或新建其他沙箱") + } if _, ok := r.Sandbox(id); !ok { return fmt.Errorf("sandbox not found") } diff --git a/internal/repo/sandbox_defaults.go b/internal/repo/sandbox_defaults.go new file mode 100644 index 0000000..072caec --- /dev/null +++ b/internal/repo/sandbox_defaults.go @@ -0,0 +1,18 @@ +package repo + +import "agentflow/internal/domain" + +const DefaultSandboxProfileID = "local" + +// DefaultSandboxProfile is available without catalog initialization. It is a +// read-only fallback, not a seed row. Operator-persisted profiles take priority. +// The runtime applies AGENTFLOW_LOCAL_SANDBOX=docker in Compose deployments; +// native installations retain their explicitly configured execution policy. +func DefaultSandboxProfile() domain.SandboxProfile { + return domain.SandboxProfile{ + ID: DefaultSandboxProfileID, + Name: "默认运行沙箱", + Shared: true, + Driver: domain.SandboxDriverLocal, + } +} diff --git a/internal/repo/sandbox_defaults_test.go b/internal/repo/sandbox_defaults_test.go new file mode 100644 index 0000000..a8bdc6a --- /dev/null +++ b/internal/repo/sandbox_defaults_test.go @@ -0,0 +1,94 @@ +package repo + +import ( + "reflect" + "strings" + "testing" + + "agentflow/internal/domain" +) + +func TestDefaultSandboxWithoutSeedDoesNotWriteRows(t *testing.T) { + s, err := OpenTest() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + if err := s.DB.Exec("DELETE FROM sandboxes").Error; err != nil { + t.Fatal(err) + } + profile, ok := s.Sandbox(DefaultSandboxProfileID) + if !ok || profile.ID != "local" || profile.Driver != domain.SandboxDriverLocal || !profile.Shared || profile.ProjectID != "" { + t.Fatalf("missing shared default: %+v, found=%v", profile, ok) + } + profiles := s.Sandboxes() + if len(profiles) != 1 || !reflect.DeepEqual(profiles[0], profile) { + t.Fatalf("list and lookup disagree: %+v / %+v", profiles, profile) + } + var count int64 + if err := s.DB.Table("sandboxes").Count(&count).Error; err != nil || count != 0 { + t.Fatalf("default lookup wrote catalog data: rows=%d, err=%v", count, err) + } + if _, ok := s.Sandbox("not-configured"); ok { + t.Fatal("an unknown sandbox must not use the default") + } +} + +func TestStoredDefaultSandboxWinsWithoutDuplicatesAndCannotBeDeleted(t *testing.T) { + s, err := OpenTest() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + configured := domain.SandboxProfile{ID: "local", Name: "Operator configured", ProjectID: "platform", Shared: true, Driver: "native", Environment: map[string]string{"PRESERVE": "yes"}} + if err := s.PutSandbox(configured); err != nil { + t.Fatal(err) + } + profile, ok := s.Sandbox(configured.ID) + if !ok || !reflect.DeepEqual(profile, configured) { + t.Fatalf("operator configuration changed: %+v", profile) + } + count := 0 + for _, candidate := range s.Sandboxes() { + if candidate.ID == configured.ID { + count++ + if !reflect.DeepEqual(candidate, configured) { + t.Fatalf("list replaced operator configuration: %+v", candidate) + } + } + } + if count != 1 { + t.Fatalf("default appears %d times", count) + } + if err := s.DeleteSandbox(configured.ID); err == nil || !strings.Contains(err.Error(), "不能删除") { + t.Fatalf("default deletion must be explicit: %v", err) + } + profile, _ = s.Sandbox(configured.ID) + if !reflect.DeepEqual(profile, configured) { + t.Fatal("rejected deletion changed the configured execution policy") + } +} + +func TestDefaultSandboxDoesNotMaskCorruptConfigurationOrDatabaseFailure(t *testing.T) { + s, err := OpenTest() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + if err := s.DB.Table("sandboxes").Where("id = ?", DefaultSandboxProfileID).Update("payload", `"invalid profile"`).Error; err != nil { + t.Fatal(err) + } + if _, ok := s.Sandbox(DefaultSandboxProfileID); ok { + t.Fatal("invalid stored configuration must not fall back to local execution") + } + if err := s.DB.Exec("DELETE FROM sandboxes").Error; err != nil { + t.Fatal(err) + } + s.Close() + if _, ok := s.Sandbox(DefaultSandboxProfileID); ok { + t.Fatal("database failure must not fall back to local execution") + } + if profiles := s.Sandboxes(); len(profiles) != 0 { + t.Fatalf("database failure reported available sandboxes: %+v", profiles) + } +} diff --git a/web/app/flows.js b/web/app/flows.js index 6dda952..284a103 100644 --- a/web/app/flows.js +++ b/web/app/flows.js @@ -1092,22 +1092,68 @@ async function saveWorkflow(){ if(catalogGeneric(state.workflow)&&!isPlatformAdmin()){toast('通用模板为只读,请先「复制到本项目」再修改');throw new Error('generic sop is read-only')} if(catalogForeign(state.workflow?.projectId)){toast('这是其他项目的共享 SOP,请先复制到本项目再改');throw new Error('shared sop is read-only')} try{const saved=await api('/api/workflows',{method:'PUT',body:JSON.stringify(state.workflow)});state.workflow=clone(saved);const i=state.data.workflows.findIndex(w=>w.id===saved.id);if(i>=0)state.data.workflows[i]=clone(saved);else state.data.workflows.unshift(clone(saved));markWorkflowClean();renderWorkflowMeta();renderSOPCatalog();renderCanvas();renderProperties();toast('SOP 草稿已保存');return saved}catch(e){if(e.message.includes('revision conflict')){toast('保存冲突:其他人已更新该 SOP,请重新选择后合并')}else if(e.message!=='shared sop is read-only'&&e.message!=='generic sop is read-only')toast(e.message);throw e}} -function newWorkflow(){state.workflow={id:uid('sop'),name:'未命名 SOP',version:1,revision:0,status:'draft',projectId:state.projectFilter||'',shared:false,owner:'',tags:[],description:'',sandboxProfileId:'local',nodes:[],edges:[]};state.selectedNode=null;rememberOpenWorkflow();renderAll();dirty()} +function sopSandboxChoices(){ + return (state.data.sandboxes||[]).filter(s=>s.id&&catalogVisible(s.projectId,s.shared)); +} +function defaultSOPSandbox(choices=sopSandboxChoices()){ + return choices.find(s=>s.id==='local')||choices.find(s=>catalogOwned(s.projectId)&&s.driver==='docker')||choices.find(s=>s.driver==='docker')||choices[0]; +} +function syncSOPSandboxSelection(){ + const select=$('#sopCreateSandbox'),template=(state.data.workflows||[]).find(w=>w.id===state.sopTemplate); + const choices=sopSandboxChoices(),sameSource=select.dataset.source===state.sopTemplate&&select.dataset.project===state.projectFilter; + const preferred=sameSource?select.value:(template?.sandboxProfileId||defaultSOPSandbox(choices)?.id||''); + // Generic adoption already selects a project/shared fallback on the server. + const selected=catalogGeneric(template) + ?choices.find(s=>s.id===template.sandboxProfileId)||choices.find(s=>catalogOwned(s.projectId))||choices[0] + :choices.find(s=>s.id===preferred); + select.innerHTML=``+choices.map(s=>``).join(''); + select.value=selected?.id||''; + select.dataset.source=state.sopTemplate;select.dataset.project=state.projectFilter; + select.disabled=catalogGeneric(template)||!choices.length; + select.onchange=()=>syncSOPSandboxSelection(); + $('#sopFinish').disabled=!selected; + $('#sopSandboxHint').textContent=!choices.length + ?lfT('当前项目没有可用的运行沙箱,请先配置或请管理员共享一个沙箱,再点击「刷新沙箱」。','No sandbox is available for this project. Configure one or ask an administrator to share one, then refresh sandboxes.') + :!selected?lfT('模板原有沙箱不可用,请重新选择运行沙箱。','The template sandbox is unavailable. Select a replacement sandbox.') + :catalogGeneric(template)?lfT('通用模板优先使用原有沙箱,不可用时自动选择本项目可用沙箱。复制后可在流程设置中修改。','Starter templates prefer their original sandbox and otherwise use an available project sandbox. You can change it in SOP settings after copying.') + :lfT('选择此 SOP 使用的运行环境,创建后仍可在流程设置中修改。','Choose the execution environment for this SOP. You can change it in SOP settings later.'); +} +async function refreshSOPSandboxes(){ + const button=$('#sopRefreshSandboxes');button.disabled=true; + try{ + state.data.sandboxes=await api('/api/sandboxes'); + // Empty installations can acquire a first sandbox without losing form input. + const select=$('#sopCreateSandbox'); + if(!select.value&&!state.data.workflows.some(w=>w.id===state.sopTemplate))delete select.dataset.source; + syncSOPSandboxSelection(); + }catch(err){toast(err.message)}finally{button.disabled=false} +} +function newWorkflow(){ + const sandbox=defaultSOPSandbox(); + if(!sandbox)return toast(lfT('当前项目没有可用的运行沙箱,请先到「设置 → 沙箱」配置。','No sandbox is available for this project. Configure one in Settings → Sandboxes first.')); + state.workflow={id:uid('sop'),name:'未命名 SOP',version:1,revision:0,status:'draft',projectId:state.projectFilter||'',shared:false,owner:'',tags:[],description:'',sandboxProfileId:sandbox.id,nodes:[],edges:[]};state.selectedNode=null;rememberOpenWorkflow();renderAll();dirty(); +} function openSOPWizard(){ - const f=$('#sopCreateForm');f.reset(); + const f=$('#sopCreateForm');f.reset();delete $('#sopCreateSandbox').dataset.source; + $('#sopRefreshSandboxes').onclick=refreshSOPSandboxes; const generic=(state.data.workflows||[]).filter(w=>catalogGeneric(w)); const recommended=(state.data.workflows||[]).filter(w=>!catalogGeneric(w)&&['issue-fix-production','requirement-development-production'].includes(w.id)); const fallback=(state.data.workflows||[]).find(w=>!catalogGeneric(w)&&w.status==='published'); const choices=[...generic,...recommended,...(fallback&&!recommended.some(w=>w.id===fallback.id)?[fallback]:[])]; state.sopTemplate=choices[0]?.id||'blank'; $('#sopTemplateChoices').innerHTML=choices.map((w,index)=>``).join('')+``; - $('#sopTemplateChoices').querySelectorAll('input').forEach(input=>input.onchange=()=>{$$('#sopTemplateChoices .template-choice').forEach(x=>x.classList.toggle('active',x.contains(input)));state.sopTemplate=input.value}); + $('#sopTemplateChoices').querySelectorAll('input').forEach(input=>input.onchange=()=>{$$('#sopTemplateChoices .template-choice').forEach(x=>x.classList.toggle('active',x.contains(input)));state.sopTemplate=input.value;syncSOPSandboxSelection()}); setSOPWizardStep(1);$('#sopCreateModal').classList.add('open');setTimeout(()=>f.elements.name.focus(),0)} -function setSOPWizardStep(step){const f=$('#sopCreateForm');if(step>1&&state.sopWizardStep===1&&!f.elements.name.value.trim())return toast('先填写一个便于团队识别的流程名称');state.sopWizardStep=Math.max(1,Math.min(3,step));f.querySelectorAll('.wizard-pane').forEach(p=>p.classList.toggle('active',Number(p.dataset.step)===state.sopWizardStep));f.querySelectorAll('[data-step-target]').forEach(b=>b.classList.toggle('active',Number(b.dataset.stepTarget)<=state.sopWizardStep));$('#sopPrev').disabled=state.sopWizardStep===1;$('#sopNext').hidden=state.sopWizardStep===3;$('#sopNext').textContent=state.sopWizardStep===1?'选择流程模板':'查看创建摘要';$('#sopFinish').hidden=state.sopWizardStep===1;$('#sopFinish').textContent=state.sopWizardStep===2?'使用此模板创建':'创建并进入设计器';$('#sopStepHint').textContent=state.sopWizardStep===1?'填写基本信息即可开始':state.sopWizardStep===2?'选择最接近的模板,后续仍可修改':'确认后进入可视化设计器';if(state.sopWizardStep===3){const template=state.data.workflows.find(w=>w.id===state.sopTemplate);$('#sopCreateSummary').innerHTML=`
流程名称${escapeHTML(f.elements.name.value)}
负责人${escapeHTML(f.elements.owner.value||'暂未指定')}
创建方式${template?`基于「${escapeHTML(template.name)}」`:'空白流程'}
初始结构${template?.nodes?.length||0} 个节点 · ${template?.edges?.length||0} 条依赖
`}} +function setSOPWizardStep(step){const f=$('#sopCreateForm');syncSOPSandboxSelection();if(step>1&&state.sopWizardStep===1&&!f.elements.name.value.trim())return toast('先填写一个便于团队识别的流程名称');state.sopWizardStep=Math.max(1,Math.min(3,step));f.querySelectorAll('.wizard-pane').forEach(p=>p.classList.toggle('active',Number(p.dataset.step)===state.sopWizardStep));f.querySelectorAll('[data-step-target]').forEach(b=>b.classList.toggle('active',Number(b.dataset.stepTarget)<=state.sopWizardStep));$('#sopPrev').disabled=state.sopWizardStep===1;$('#sopNext').hidden=state.sopWizardStep===3;$('#sopNext').textContent=state.sopWizardStep===1?'选择流程模板':'查看创建摘要';$('#sopFinish').hidden=state.sopWizardStep===1;$('#sopFinish').textContent=state.sopWizardStep===2?'使用此模板创建':'创建并进入设计器';$('#sopStepHint').textContent=state.sopWizardStep===1?'填写基本信息即可开始':state.sopWizardStep===2?'选择最接近的模板,后续仍可修改':'确认后进入可视化设计器';if(state.sopWizardStep===3){const template=state.data.workflows.find(w=>w.id===state.sopTemplate);$('#sopCreateSummary').innerHTML=`
流程名称${escapeHTML(f.elements.name.value)}
负责人${escapeHTML(f.elements.owner.value||'暂未指定')}
创建方式${template?`基于「${escapeHTML(template.name)}」`:'空白流程'}
${escapeHTML(lfT('运行沙箱','Run sandbox'))}${escapeHTML(sopSandboxChoices().find(s=>s.id===f.elements.sandboxProfileId.value)?.name||lfT('未选择','Not selected'))}
初始结构${template?.nodes?.length||0} 个节点 · ${template?.edges?.length||0} 条依赖
`}} async function createSOPFromWizard(e){ e.preventDefault(); const f=e.currentTarget,template=(state.data.workflows||[]).find(w=>w.id===state.sopTemplate); const name=f.elements.name.value.trim(),description=f.elements.description.value.trim(),owner=f.elements.owner.value.trim(),tags=f.elements.tags.value.split(',').map(x=>x.trim()).filter(Boolean); + const sandboxId=f.elements.sandboxProfileId.value; + if(!sopSandboxChoices().some(s=>s.id===sandboxId)){ + syncSOPSandboxSelection(); + return toast($('#sopSandboxHint').textContent); + } try{ let saved; if(catalogGeneric(template)){ @@ -1119,7 +1165,7 @@ async function createSOPFromWizard(e){ }else{ const workflow=template?clone(template):{nodes:[],edges:[]}; Object.assign(workflow,{id:uid('sop'),name,description,owner,tags,status:'draft',version:1,revision:0,projectId:state.projectFilter||'',shared:false}); - workflow.sandboxProfileId=workflow.sandboxProfileId||'local'; + workflow.sandboxProfileId=sandboxId; delete workflow.createdAt;delete workflow.updatedAt; saved=await api('/api/workflows',{method:'PUT',body:JSON.stringify(workflow)}); } @@ -1127,7 +1173,11 @@ async function createSOPFromWizard(e){ $('#sopCreateModal').classList.remove('open');renderAll();switchView('flow'); const missing=catalogGeneric(template)?genericRequirementRows(saved).filter(r=>!r.ok):[]; toast(missing.length?`SOP 草稿已创建;${missing.length} 项运行依赖未配置(${missing.map(r=>r.label).join('、')})`:'SOP 草稿已创建,请继续设计节点',missing.length?'':'ok') - }catch(err){toast(err.message)}} + }catch(err){ + toast(/sandbox profile.*not found/i.test(err.message) + ?lfT('所选运行沙箱已不可用,请点击「刷新沙箱」后重新选择。','The selected sandbox is no longer available. Refresh sandboxes and select another one.') + :err.message); + }} async function cloneWorkflow(id){const src=id?(state.data.workflows||[]).find(w=>w.id===id):state.workflow;if(!src?.id)return toast('先选择一条 SOP');const name=await afPrompt({title:'复制 SOP',message:'请输入复制后的 SOP 名称:',defaultValue:`${src.name} - 副本`});if(!name)return;const owner=await afPrompt({title:'SOP 负责人',message:'请输入负责人(可留空):',defaultValue:src.owner||''})??'';try{const copied=await api(`/api/workflows/${encodeURIComponent(src.id)}/clone`,{method:'POST',body:JSON.stringify({id:uid('sop'),name,owner,projectId:state.projectFilter||''})});state.data.workflows.unshift(copied);state.workflow=clone(copied);state.selectedNode=null;state.sopScope='project';markWorkflowClean();renderAll();toast('已复制到本项目')}catch(e){toast(e.message)}} async function deleteWorkflow(id){ diff --git a/web/i18n-en.js b/web/i18n-en.js index 84fc598..b78ffe9 100644 --- a/web/i18n-en.js +++ b/web/i18n-en.js @@ -113,5 +113,9 @@ Object.assign(window.LF_I18N_EN,{ "当前列表":"In this list,", "项不可删除,查看原因":"cannot be deleted. Show why", "选择此需求":"Select this requirement", -"选择需求":"Select requirement" +"选择需求":"Select requirement", +"配置沙箱":"Configure sandbox", +"刷新沙箱":"Refresh sandboxes", +"默认运行沙箱":"Default sandbox", +"默认运行沙箱不能删除;可编辑配置或新建其他沙箱":"The default sandbox cannot be deleted; edit its settings or create another sandbox." }); diff --git a/web/index.html b/web/index.html index b5265c8..a6d9425 100644 --- a/web/index.html +++ b/web/index.html @@ -983,7 +983,7 @@

新建角色

- +