Repository: agentscope-ai/CoPaw Branch: main Commit: 82a49d77890b Files: 814 Total size: 16.6 MB Directory structure: gitextract_9wv9z_k8/ ├── .dockerignore ├── .flake8 ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── 1-question.md │ │ ├── 2-feature_request.md │ │ ├── 3-documentation.md │ │ ├── 4-bug_report.md │ │ ├── 5-support_environment.md │ │ └── config.yml │ ├── PULL_REQUEST_TEMPLATE.md │ ├── condarc │ └── workflows/ │ ├── deploy-website.yml │ ├── desktop-release.yml │ ├── docker-release.yml │ ├── first-time-contributor-welcome.yml │ ├── npm-format.yml │ ├── pr-label.yml │ ├── pre-commit.yml │ ├── publish-pypi.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── CONTRIBUTING.md ├── CONTRIBUTING_zh.md ├── LICENSE ├── README.md ├── README_ja.md ├── README_zh.md ├── SECURITY.md ├── console/ │ ├── eslint.config.js │ ├── index.html │ ├── package.json │ ├── src/ │ │ ├── App.tsx │ │ ├── api/ │ │ │ ├── config.ts │ │ │ ├── index.ts │ │ │ ├── modules/ │ │ │ │ ├── agent.ts │ │ │ │ ├── agents.ts │ │ │ │ ├── auth.ts │ │ │ │ ├── channel.ts │ │ │ │ ├── chat.ts │ │ │ │ ├── console.ts │ │ │ │ ├── cronjob.ts │ │ │ │ ├── env.ts │ │ │ │ ├── heartbeat.ts │ │ │ │ ├── localModel.ts │ │ │ │ ├── mcp.ts │ │ │ │ ├── ollamaModel.ts │ │ │ │ ├── provider.ts │ │ │ │ ├── root.ts │ │ │ │ ├── security.ts │ │ │ │ ├── skill.ts │ │ │ │ ├── tokenUsage.ts │ │ │ │ ├── tools.ts │ │ │ │ ├── userTimezone.ts │ │ │ │ └── workspace.ts │ │ │ ├── request.ts │ │ │ └── types/ │ │ │ ├── agent.ts │ │ │ ├── agents.ts │ │ │ ├── channel.ts │ │ │ ├── chat.ts │ │ │ ├── cronjob.ts │ │ │ ├── env.ts │ │ │ ├── heartbeat.ts │ │ │ ├── index.ts │ │ │ ├── mcp.ts │ │ │ ├── provider.ts │ │ │ ├── skill.ts │ │ │ ├── tokenUsage.ts │ │ │ └── workspace.ts │ │ ├── components/ │ │ │ ├── AgentSelector/ │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ │ ├── ConsoleCronBubble/ │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ │ ├── LanguageSwitcher.tsx │ │ │ ├── MarkdownCopy/ │ │ │ │ ├── MarkdownCopy.tsx │ │ │ │ └── index.module.less │ │ │ ├── PageHeader/ │ │ │ │ └── index.tsx │ │ │ └── ThemeToggleButton/ │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ ├── constants/ │ │ │ └── timezone.ts │ │ ├── contexts/ │ │ │ └── ThemeContext.tsx │ │ ├── i18n.ts │ │ ├── layouts/ │ │ │ ├── Header.tsx │ │ │ ├── MainLayout/ │ │ │ │ └── index.tsx │ │ │ ├── Sidebar.tsx │ │ │ ├── constants.ts │ │ │ └── index.module.less │ │ ├── locales/ │ │ │ ├── en.json │ │ │ ├── ja.json │ │ │ ├── ru.json │ │ │ └── zh.json │ │ ├── main.tsx │ │ ├── pages/ │ │ │ ├── Agent/ │ │ │ │ ├── Config/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── ContextManagementCard.tsx │ │ │ │ │ │ ├── PageHeader.tsx │ │ │ │ │ │ ├── ReactAgentCard.tsx │ │ │ │ │ │ ├── SliderWithValue.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.module.less │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── useAgentConfig.tsx │ │ │ │ ├── MCP/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── MCPClientCard.tsx │ │ │ │ │ │ ├── MCPClientDrawer.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.module.less │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── useMCP.ts │ │ │ │ ├── Skills/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── SkillCard.tsx │ │ │ │ │ │ ├── SkillDrawer.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.module.less │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── useSkills.ts │ │ │ │ ├── Tools/ │ │ │ │ │ ├── index.module.less │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── useTools.ts │ │ │ │ └── Workspace/ │ │ │ │ ├── components/ │ │ │ │ │ ├── FileEditor.tsx │ │ │ │ │ ├── FileItem.tsx │ │ │ │ │ ├── FileListPanel.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── useAgentsData.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ │ ├── Chat/ │ │ │ │ ├── ModelSelector/ │ │ │ │ │ ├── index.module.less │ │ │ │ │ └── index.tsx │ │ │ │ ├── OptionsPanel/ │ │ │ │ │ ├── FormItem.tsx │ │ │ │ │ ├── OptionsEditor.tsx │ │ │ │ │ ├── defaultConfig.ts │ │ │ │ │ └── index.tsx │ │ │ │ ├── index.module.less │ │ │ │ ├── index.tsx │ │ │ │ └── sessionApi/ │ │ │ │ └── index.ts │ │ │ ├── Control/ │ │ │ │ ├── Channels/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── ChannelCard.tsx │ │ │ │ │ │ ├── ChannelDrawer.tsx │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.module.less │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── useChannels.ts │ │ │ │ ├── CronJobs/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── JobDrawer.tsx │ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── parseCron.ts │ │ │ │ │ ├── index.module.less │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── useCronJobs.ts │ │ │ │ ├── Heartbeat/ │ │ │ │ │ ├── index.module.less │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── parseEvery.ts │ │ │ │ └── Sessions/ │ │ │ │ ├── components/ │ │ │ │ │ ├── FilterBar.tsx │ │ │ │ │ ├── SessionDrawer.tsx │ │ │ │ │ ├── columns.tsx │ │ │ │ │ ├── constants.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.module.less │ │ │ │ ├── index.tsx │ │ │ │ └── useSessions.ts │ │ │ ├── Login/ │ │ │ │ └── index.tsx │ │ │ └── Settings/ │ │ │ ├── Agents/ │ │ │ │ ├── components/ │ │ │ │ │ ├── AgentModal.tsx │ │ │ │ │ ├── AgentTable.tsx │ │ │ │ │ ├── PageHeader.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── index.module.less │ │ │ │ ├── index.tsx │ │ │ │ └── useAgents.ts │ │ │ ├── Environments/ │ │ │ │ ├── components/ │ │ │ │ │ ├── AddButton.tsx │ │ │ │ │ ├── EmptyState.tsx │ │ │ │ │ ├── EnvRow.tsx │ │ │ │ │ ├── PageHeader.tsx │ │ │ │ │ ├── Toolbar.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── index.module.less │ │ │ │ ├── index.tsx │ │ │ │ └── useEnvVars.ts │ │ │ ├── Models/ │ │ │ │ ├── components/ │ │ │ │ │ ├── ModelManageModal.tsx │ │ │ │ │ ├── cards/ │ │ │ │ │ │ ├── LocalProviderCard.tsx │ │ │ │ │ │ ├── ProviderCard.tsx │ │ │ │ │ │ ├── RemoteProviderCard.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── modals/ │ │ │ │ │ │ ├── CustomProviderModal.tsx │ │ │ │ │ │ ├── LocalModelManageModal.tsx │ │ │ │ │ │ ├── ModelManageModal.tsx │ │ │ │ │ │ ├── OllamaModelManageModal.tsx │ │ │ │ │ │ ├── ProviderConfigModal.tsx │ │ │ │ │ │ ├── RemoteModelManageModal.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── sections/ │ │ │ │ │ ├── LoadingState.tsx │ │ │ │ │ ├── ModelsSection.tsx │ │ │ │ │ ├── PageHeader.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── index.module.less │ │ │ │ ├── index.tsx │ │ │ │ └── useProviders.ts │ │ │ ├── Security/ │ │ │ │ ├── components/ │ │ │ │ │ ├── PageHeader.tsx │ │ │ │ │ ├── PreviewModal.tsx │ │ │ │ │ ├── RuleModal.tsx │ │ │ │ │ ├── RuleTable.tsx │ │ │ │ │ ├── SkillScannerSection.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── index.module.less │ │ │ │ ├── index.tsx │ │ │ │ ├── useSkillScanner.ts │ │ │ │ └── useToolGuard.ts │ │ │ ├── TokenUsage/ │ │ │ │ ├── components/ │ │ │ │ │ ├── EmptyState.tsx │ │ │ │ │ ├── LoadingState.tsx │ │ │ │ │ ├── PageHeader.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ │ └── VoiceTranscription/ │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ ├── stores/ │ │ │ └── agentStore.ts │ │ ├── styles/ │ │ │ ├── form-override.css │ │ │ └── layout.css │ │ ├── utils/ │ │ │ ├── formatNumber.ts │ │ │ └── markdown.ts │ │ └── vite-env.d.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── deploy/ │ ├── Dockerfile │ ├── config/ │ │ └── supervisord.conf.template │ └── entrypoint.sh ├── docker-compose.yml ├── pyproject.toml ├── scripts/ │ ├── README.md │ ├── docker_build.sh │ ├── docker_sync_latest.sh │ ├── install.bat │ ├── install.ps1 │ ├── install.sh │ ├── pack/ │ │ ├── README.md │ │ ├── README_zh.md │ │ ├── assets/ │ │ │ └── icon.icns │ │ ├── build_common.py │ │ ├── build_macos.sh │ │ ├── build_win.ps1 │ │ └── copaw_desktop.nsi │ ├── run_tests.py │ ├── website_build.sh │ ├── wheel_build.ps1 │ └── wheel_build.sh ├── setup.py ├── src/ │ └── copaw/ │ ├── __init__.py │ ├── __main__.py │ ├── __version__.py │ ├── agents/ │ │ ├── __init__.py │ │ ├── command_handler.py │ │ ├── hooks/ │ │ │ ├── __init__.py │ │ │ ├── bootstrap.py │ │ │ └── memory_compaction.py │ │ ├── md_files/ │ │ │ ├── en/ │ │ │ │ ├── AGENTS.md │ │ │ │ ├── BOOTSTRAP.md │ │ │ │ ├── HEARTBEAT.md │ │ │ │ ├── MEMORY.md │ │ │ │ ├── PROFILE.md │ │ │ │ └── SOUL.md │ │ │ ├── ru/ │ │ │ │ ├── AGENTS.md │ │ │ │ ├── BOOTSTRAP.md │ │ │ │ ├── HEARTBEAT.md │ │ │ │ ├── MEMORY.md │ │ │ │ ├── PROFILE.md │ │ │ │ └── SOUL.md │ │ │ └── zh/ │ │ │ ├── AGENTS.md │ │ │ ├── BOOTSTRAP.md │ │ │ ├── HEARTBEAT.md │ │ │ ├── MEMORY.md │ │ │ ├── PROFILE.md │ │ │ └── SOUL.md │ │ ├── memory/ │ │ │ ├── __init__.py │ │ │ ├── agent_md_manager.py │ │ │ └── memory_manager.py │ │ ├── model_factory.py │ │ ├── prompt.py │ │ ├── react_agent.py │ │ ├── routing_chat_model.py │ │ ├── schema.py │ │ ├── skills/ │ │ │ ├── __init__.py │ │ │ ├── browser_visible/ │ │ │ │ └── SKILL.md │ │ │ ├── cron/ │ │ │ │ └── SKILL.md │ │ │ ├── dingtalk_channel/ │ │ │ │ └── SKILL.md │ │ │ ├── docx/ │ │ │ │ ├── LICENSE.txt │ │ │ │ ├── SKILL.md │ │ │ │ └── scripts/ │ │ │ │ ├── __init__.py │ │ │ │ ├── accept_changes.py │ │ │ │ ├── comment.py │ │ │ │ ├── office/ │ │ │ │ │ ├── helpers/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── merge_runs.py │ │ │ │ │ │ └── simplify_redlines.py │ │ │ │ │ ├── pack.py │ │ │ │ │ ├── schemas/ │ │ │ │ │ │ ├── ISO-IEC29500-4_2016/ │ │ │ │ │ │ │ ├── dml-chart.xsd │ │ │ │ │ │ │ ├── dml-chartDrawing.xsd │ │ │ │ │ │ │ ├── dml-diagram.xsd │ │ │ │ │ │ │ ├── dml-lockedCanvas.xsd │ │ │ │ │ │ │ ├── dml-main.xsd │ │ │ │ │ │ │ ├── dml-picture.xsd │ │ │ │ │ │ │ ├── dml-spreadsheetDrawing.xsd │ │ │ │ │ │ │ ├── dml-wordprocessingDrawing.xsd │ │ │ │ │ │ │ ├── pml.xsd │ │ │ │ │ │ │ ├── shared-additionalCharacteristics.xsd │ │ │ │ │ │ │ ├── shared-bibliography.xsd │ │ │ │ │ │ │ ├── shared-commonSimpleTypes.xsd │ │ │ │ │ │ │ ├── shared-customXmlDataProperties.xsd │ │ │ │ │ │ │ ├── shared-customXmlSchemaProperties.xsd │ │ │ │ │ │ │ ├── shared-documentPropertiesCustom.xsd │ │ │ │ │ │ │ ├── shared-documentPropertiesExtended.xsd │ │ │ │ │ │ │ ├── shared-documentPropertiesVariantTypes.xsd │ │ │ │ │ │ │ ├── shared-math.xsd │ │ │ │ │ │ │ ├── shared-relationshipReference.xsd │ │ │ │ │ │ │ ├── sml.xsd │ │ │ │ │ │ │ ├── vml-main.xsd │ │ │ │ │ │ │ ├── vml-officeDrawing.xsd │ │ │ │ │ │ │ ├── vml-presentationDrawing.xsd │ │ │ │ │ │ │ ├── vml-spreadsheetDrawing.xsd │ │ │ │ │ │ │ ├── vml-wordprocessingDrawing.xsd │ │ │ │ │ │ │ ├── wml.xsd │ │ │ │ │ │ │ └── xml.xsd │ │ │ │ │ │ ├── ecma/ │ │ │ │ │ │ │ └── fouth-edition/ │ │ │ │ │ │ │ ├── opc-contentTypes.xsd │ │ │ │ │ │ │ ├── opc-coreProperties.xsd │ │ │ │ │ │ │ ├── opc-digSig.xsd │ │ │ │ │ │ │ └── opc-relationships.xsd │ │ │ │ │ │ ├── mce/ │ │ │ │ │ │ │ └── mc.xsd │ │ │ │ │ │ └── microsoft/ │ │ │ │ │ │ ├── wml-2010.xsd │ │ │ │ │ │ ├── wml-2012.xsd │ │ │ │ │ │ ├── wml-2018.xsd │ │ │ │ │ │ ├── wml-cex-2018.xsd │ │ │ │ │ │ ├── wml-cid-2016.xsd │ │ │ │ │ │ ├── wml-sdtdatahash-2020.xsd │ │ │ │ │ │ └── wml-symex-2015.xsd │ │ │ │ │ ├── soffice.py │ │ │ │ │ ├── unpack.py │ │ │ │ │ ├── validate.py │ │ │ │ │ └── validators/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── base.py │ │ │ │ │ ├── docx.py │ │ │ │ │ ├── pptx.py │ │ │ │ │ └── redlining.py │ │ │ │ └── templates/ │ │ │ │ ├── comments.xml │ │ │ │ ├── commentsExtended.xml │ │ │ │ ├── commentsExtensible.xml │ │ │ │ ├── commentsIds.xml │ │ │ │ └── people.xml │ │ │ ├── file_reader/ │ │ │ │ └── SKILL.md │ │ │ ├── guidance/ │ │ │ │ └── SKILL.md │ │ │ ├── himalaya/ │ │ │ │ ├── SKILL.md │ │ │ │ └── references/ │ │ │ │ └── configuration.md │ │ │ ├── news/ │ │ │ │ └── SKILL.md │ │ │ ├── pdf/ │ │ │ │ ├── LICENSE.txt │ │ │ │ ├── SKILL.md │ │ │ │ ├── forms.md │ │ │ │ ├── reference.md │ │ │ │ └── scripts/ │ │ │ │ ├── check_bounding_boxes.py │ │ │ │ ├── check_fillable_fields.py │ │ │ │ ├── convert_pdf_to_images.py │ │ │ │ ├── create_validation_image.py │ │ │ │ ├── extract_form_field_info.py │ │ │ │ ├── extract_form_structure.py │ │ │ │ ├── fill_fillable_fields.py │ │ │ │ └── fill_pdf_form_with_annotations.py │ │ │ ├── pptx/ │ │ │ │ ├── LICENSE.txt │ │ │ │ ├── SKILL.md │ │ │ │ ├── editing.md │ │ │ │ ├── pptxgenjs.md │ │ │ │ └── scripts/ │ │ │ │ ├── __init__.py │ │ │ │ ├── add_slide.py │ │ │ │ ├── clean.py │ │ │ │ ├── office/ │ │ │ │ │ ├── helpers/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── merge_runs.py │ │ │ │ │ │ └── simplify_redlines.py │ │ │ │ │ ├── pack.py │ │ │ │ │ ├── schemas/ │ │ │ │ │ │ ├── ISO-IEC29500-4_2016/ │ │ │ │ │ │ │ ├── dml-chart.xsd │ │ │ │ │ │ │ ├── dml-chartDrawing.xsd │ │ │ │ │ │ │ ├── dml-diagram.xsd │ │ │ │ │ │ │ ├── dml-lockedCanvas.xsd │ │ │ │ │ │ │ ├── dml-main.xsd │ │ │ │ │ │ │ ├── dml-picture.xsd │ │ │ │ │ │ │ ├── dml-spreadsheetDrawing.xsd │ │ │ │ │ │ │ ├── dml-wordprocessingDrawing.xsd │ │ │ │ │ │ │ ├── pml.xsd │ │ │ │ │ │ │ ├── shared-additionalCharacteristics.xsd │ │ │ │ │ │ │ ├── shared-bibliography.xsd │ │ │ │ │ │ │ ├── shared-commonSimpleTypes.xsd │ │ │ │ │ │ │ ├── shared-customXmlDataProperties.xsd │ │ │ │ │ │ │ ├── shared-customXmlSchemaProperties.xsd │ │ │ │ │ │ │ ├── shared-documentPropertiesCustom.xsd │ │ │ │ │ │ │ ├── shared-documentPropertiesExtended.xsd │ │ │ │ │ │ │ ├── shared-documentPropertiesVariantTypes.xsd │ │ │ │ │ │ │ ├── shared-math.xsd │ │ │ │ │ │ │ ├── shared-relationshipReference.xsd │ │ │ │ │ │ │ ├── sml.xsd │ │ │ │ │ │ │ ├── vml-main.xsd │ │ │ │ │ │ │ ├── vml-officeDrawing.xsd │ │ │ │ │ │ │ ├── vml-presentationDrawing.xsd │ │ │ │ │ │ │ ├── vml-spreadsheetDrawing.xsd │ │ │ │ │ │ │ ├── vml-wordprocessingDrawing.xsd │ │ │ │ │ │ │ ├── wml.xsd │ │ │ │ │ │ │ └── xml.xsd │ │ │ │ │ │ ├── ecma/ │ │ │ │ │ │ │ └── fouth-edition/ │ │ │ │ │ │ │ ├── opc-contentTypes.xsd │ │ │ │ │ │ │ ├── opc-coreProperties.xsd │ │ │ │ │ │ │ ├── opc-digSig.xsd │ │ │ │ │ │ │ └── opc-relationships.xsd │ │ │ │ │ │ ├── mce/ │ │ │ │ │ │ │ └── mc.xsd │ │ │ │ │ │ └── microsoft/ │ │ │ │ │ │ ├── wml-2010.xsd │ │ │ │ │ │ ├── wml-2012.xsd │ │ │ │ │ │ ├── wml-2018.xsd │ │ │ │ │ │ ├── wml-cex-2018.xsd │ │ │ │ │ │ ├── wml-cid-2016.xsd │ │ │ │ │ │ ├── wml-sdtdatahash-2020.xsd │ │ │ │ │ │ └── wml-symex-2015.xsd │ │ │ │ │ ├── soffice.py │ │ │ │ │ ├── unpack.py │ │ │ │ │ ├── validate.py │ │ │ │ │ └── validators/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── base.py │ │ │ │ │ ├── docx.py │ │ │ │ │ ├── pptx.py │ │ │ │ │ └── redlining.py │ │ │ │ └── thumbnail.py │ │ │ └── xlsx/ │ │ │ ├── LICENSE.txt │ │ │ ├── SKILL.md │ │ │ └── scripts/ │ │ │ ├── office/ │ │ │ │ ├── helpers/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── merge_runs.py │ │ │ │ │ └── simplify_redlines.py │ │ │ │ ├── pack.py │ │ │ │ ├── schemas/ │ │ │ │ │ ├── ISO-IEC29500-4_2016/ │ │ │ │ │ │ ├── dml-chart.xsd │ │ │ │ │ │ ├── dml-chartDrawing.xsd │ │ │ │ │ │ ├── dml-diagram.xsd │ │ │ │ │ │ ├── dml-lockedCanvas.xsd │ │ │ │ │ │ ├── dml-main.xsd │ │ │ │ │ │ ├── dml-picture.xsd │ │ │ │ │ │ ├── dml-spreadsheetDrawing.xsd │ │ │ │ │ │ ├── dml-wordprocessingDrawing.xsd │ │ │ │ │ │ ├── pml.xsd │ │ │ │ │ │ ├── shared-additionalCharacteristics.xsd │ │ │ │ │ │ ├── shared-bibliography.xsd │ │ │ │ │ │ ├── shared-commonSimpleTypes.xsd │ │ │ │ │ │ ├── shared-customXmlDataProperties.xsd │ │ │ │ │ │ ├── shared-customXmlSchemaProperties.xsd │ │ │ │ │ │ ├── shared-documentPropertiesCustom.xsd │ │ │ │ │ │ ├── shared-documentPropertiesExtended.xsd │ │ │ │ │ │ ├── shared-documentPropertiesVariantTypes.xsd │ │ │ │ │ │ ├── shared-math.xsd │ │ │ │ │ │ ├── shared-relationshipReference.xsd │ │ │ │ │ │ ├── sml.xsd │ │ │ │ │ │ ├── vml-main.xsd │ │ │ │ │ │ ├── vml-officeDrawing.xsd │ │ │ │ │ │ ├── vml-presentationDrawing.xsd │ │ │ │ │ │ ├── vml-spreadsheetDrawing.xsd │ │ │ │ │ │ ├── vml-wordprocessingDrawing.xsd │ │ │ │ │ │ ├── wml.xsd │ │ │ │ │ │ └── xml.xsd │ │ │ │ │ ├── ecma/ │ │ │ │ │ │ └── fouth-edition/ │ │ │ │ │ │ ├── opc-contentTypes.xsd │ │ │ │ │ │ ├── opc-coreProperties.xsd │ │ │ │ │ │ ├── opc-digSig.xsd │ │ │ │ │ │ └── opc-relationships.xsd │ │ │ │ │ ├── mce/ │ │ │ │ │ │ └── mc.xsd │ │ │ │ │ └── microsoft/ │ │ │ │ │ ├── wml-2010.xsd │ │ │ │ │ ├── wml-2012.xsd │ │ │ │ │ ├── wml-2018.xsd │ │ │ │ │ ├── wml-cex-2018.xsd │ │ │ │ │ ├── wml-cid-2016.xsd │ │ │ │ │ ├── wml-sdtdatahash-2020.xsd │ │ │ │ │ └── wml-symex-2015.xsd │ │ │ │ ├── soffice.py │ │ │ │ ├── unpack.py │ │ │ │ ├── validate.py │ │ │ │ └── validators/ │ │ │ │ ├── __init__.py │ │ │ │ ├── base.py │ │ │ │ ├── docx.py │ │ │ │ ├── pptx.py │ │ │ │ └── redlining.py │ │ │ └── recalc.py │ │ ├── skills_hub.py │ │ ├── skills_manager.py │ │ ├── tool_guard_mixin.py │ │ ├── tools/ │ │ │ ├── __init__.py │ │ │ ├── browser_control.py │ │ │ ├── browser_snapshot.py │ │ │ ├── desktop_screenshot.py │ │ │ ├── file_io.py │ │ │ ├── file_search.py │ │ │ ├── get_current_time.py │ │ │ ├── get_token_usage.py │ │ │ ├── memory_search.py │ │ │ ├── send_file.py │ │ │ ├── shell.py │ │ │ ├── utils.py │ │ │ └── view_image.py │ │ └── utils/ │ │ ├── __init__.py │ │ ├── audio_transcription.py │ │ ├── copaw_token_counter.py │ │ ├── file_handling.py │ │ ├── message_processing.py │ │ ├── setup_utils.py │ │ └── tool_message_utils.py │ ├── app/ │ │ ├── __init__.py │ │ ├── _app.py │ │ ├── agent_config_watcher.py │ │ ├── agent_context.py │ │ ├── approvals/ │ │ │ ├── __init__.py │ │ │ └── service.py │ │ ├── auth.py │ │ ├── channels/ │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── console/ │ │ │ │ ├── __init__.py │ │ │ │ └── channel.py │ │ │ ├── dingtalk/ │ │ │ │ ├── __init__.py │ │ │ │ ├── ai_card.py │ │ │ │ ├── channel.py │ │ │ │ ├── constants.py │ │ │ │ ├── content_utils.py │ │ │ │ ├── handler.py │ │ │ │ ├── markdown.py │ │ │ │ └── utils.py │ │ │ ├── discord_/ │ │ │ │ ├── __init__.py │ │ │ │ └── channel.py │ │ │ ├── feishu/ │ │ │ │ ├── __init__.py │ │ │ │ ├── channel.py │ │ │ │ ├── constants.py │ │ │ │ └── utils.py │ │ │ ├── imessage/ │ │ │ │ ├── __init__.py │ │ │ │ └── channel.py │ │ │ ├── manager.py │ │ │ ├── matrix/ │ │ │ │ ├── __init__.py │ │ │ │ └── channel.py │ │ │ ├── mattermost/ │ │ │ │ ├── __init__.py │ │ │ │ └── channel.py │ │ │ ├── mqtt/ │ │ │ │ ├── __init__.py │ │ │ │ └── channel.py │ │ │ ├── qq/ │ │ │ │ ├── __init__.py │ │ │ │ └── channel.py │ │ │ ├── registry.py │ │ │ ├── renderer.py │ │ │ ├── schema.py │ │ │ ├── telegram/ │ │ │ │ ├── __init__.py │ │ │ │ ├── channel.py │ │ │ │ └── format_html.py │ │ │ ├── utils.py │ │ │ ├── voice/ │ │ │ │ ├── __init__.py │ │ │ │ ├── channel.py │ │ │ │ ├── conversation_relay.py │ │ │ │ ├── session.py │ │ │ │ ├── twilio_manager.py │ │ │ │ └── twiml.py │ │ │ ├── wecom/ │ │ │ │ ├── __init__.py │ │ │ │ ├── channel.py │ │ │ │ └── utils.py │ │ │ └── xiaoyi/ │ │ │ ├── __init__.py │ │ │ ├── auth.py │ │ │ ├── channel.py │ │ │ ├── constants.py │ │ │ └── utils.py │ │ ├── console_push_store.py │ │ ├── crons/ │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ ├── executor.py │ │ │ ├── heartbeat.py │ │ │ ├── manager.py │ │ │ ├── models.py │ │ │ └── repo/ │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ └── json_repo.py │ │ ├── download_task_store.py │ │ ├── mcp/ │ │ │ ├── __init__.py │ │ │ ├── manager.py │ │ │ └── watcher.py │ │ ├── migration.py │ │ ├── multi_agent_manager.py │ │ ├── routers/ │ │ │ ├── __init__.py │ │ │ ├── agent.py │ │ │ ├── agent_scoped.py │ │ │ ├── agents.py │ │ │ ├── auth.py │ │ │ ├── config.py │ │ │ ├── console.py │ │ │ ├── envs.py │ │ │ ├── local_models.py │ │ │ ├── mcp.py │ │ │ ├── ollama_models.py │ │ │ ├── providers.py │ │ │ ├── schemas_config.py │ │ │ ├── skills.py │ │ │ ├── skills_stream.py │ │ │ ├── token_usage.py │ │ │ ├── tools.py │ │ │ ├── voice.py │ │ │ └── workspace.py │ │ ├── runner/ │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ ├── command_dispatch.py │ │ │ ├── daemon_commands.py │ │ │ ├── manager.py │ │ │ ├── models.py │ │ │ ├── query_error_dump.py │ │ │ ├── repo/ │ │ │ │ ├── __init__.py │ │ │ │ ├── base.py │ │ │ │ └── json_repo.py │ │ │ ├── runner.py │ │ │ ├── session.py │ │ │ ├── task_tracker.py │ │ │ └── utils.py │ │ └── workspace/ │ │ ├── __init__.py │ │ ├── service_factories.py │ │ ├── service_manager.py │ │ └── workspace.py │ ├── cli/ │ │ ├── __init__.py │ │ ├── app_cmd.py │ │ ├── auth_cmd.py │ │ ├── channels_cmd.py │ │ ├── chats_cmd.py │ │ ├── clean_cmd.py │ │ ├── cron_cmd.py │ │ ├── daemon_cmd.py │ │ ├── desktop_cmd.py │ │ ├── env_cmd.py │ │ ├── http.py │ │ ├── init_cmd.py │ │ ├── main.py │ │ ├── process_utils.py │ │ ├── providers_cmd.py │ │ ├── shutdown_cmd.py │ │ ├── skills_cmd.py │ │ ├── uninstall_cmd.py │ │ ├── update_cmd.py │ │ └── utils.py │ ├── config/ │ │ ├── __init__.py │ │ ├── config.py │ │ ├── context.py │ │ ├── timezone.py │ │ └── utils.py │ ├── constant.py │ ├── envs/ │ │ ├── __init__.py │ │ └── store.py │ ├── local_models/ │ │ ├── __init__.py │ │ ├── backends/ │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── llamacpp_backend.py │ │ │ └── mlx_backend.py │ │ ├── chat_model.py │ │ ├── factory.py │ │ ├── manager.py │ │ ├── schema.py │ │ └── tag_parser.py │ ├── providers/ │ │ ├── __init__.py │ │ ├── anthropic_provider.py │ │ ├── gemini_provider.py │ │ ├── models.py │ │ ├── ollama_manager.py │ │ ├── ollama_provider.py │ │ ├── openai_chat_model_compat.py │ │ ├── openai_provider.py │ │ ├── provider.py │ │ ├── provider_manager.py │ │ └── retry_chat_model.py │ ├── security/ │ │ ├── __init__.py │ │ ├── skill_scanner/ │ │ │ ├── __init__.py │ │ │ ├── analyzers/ │ │ │ │ ├── __init__.py │ │ │ │ └── pattern_analyzer.py │ │ │ ├── data/ │ │ │ │ └── default_policy.yaml │ │ │ ├── models.py │ │ │ ├── rules/ │ │ │ │ └── signatures/ │ │ │ │ ├── command_injection.yaml │ │ │ │ ├── data_exfiltration.yaml │ │ │ │ ├── hardcoded_secrets.yaml │ │ │ │ ├── obfuscation.yaml │ │ │ │ ├── prompt_injection.yaml │ │ │ │ ├── resource_abuse.yaml │ │ │ │ ├── social_engineering.yaml │ │ │ │ ├── supply_chain.yaml │ │ │ │ └── unauthorized_tool_use.yaml │ │ │ ├── scan_policy.py │ │ │ └── scanner.py │ │ └── tool_guard/ │ │ ├── __init__.py │ │ ├── approval.py │ │ ├── engine.py │ │ ├── guardians/ │ │ │ ├── __init__.py │ │ │ └── rule_guardian.py │ │ ├── models.py │ │ ├── rules/ │ │ │ └── dangerous_shell_commands.yaml │ │ └── utils.py │ ├── token_usage/ │ │ ├── __init__.py │ │ ├── manager.py │ │ └── model_wrapper.py │ ├── tokenizer/ │ │ ├── merges.txt │ │ ├── tokenizer.json │ │ ├── tokenizer_config.json │ │ └── vocab.json │ ├── tunnel/ │ │ ├── __init__.py │ │ ├── binary_manager.py │ │ └── cloudflare.py │ └── utils/ │ ├── __init__.py │ ├── logging.py │ └── telemetry.py ├── tests/ │ ├── __init__.py │ ├── integrated/ │ │ ├── test_app_startup.py │ │ └── test_version.py │ └── unit/ │ ├── channels/ │ │ ├── __init__.py │ │ └── test_qq_channel.py │ ├── cli/ │ │ ├── test_cli_shutdown.py │ │ ├── test_cli_update.py │ │ └── test_cli_version.py │ ├── memory/ │ │ └── test_copaw_token_counter.py │ ├── providers/ │ │ ├── test_anthropic_provider.py │ │ ├── test_default_provider.py │ │ ├── test_gemini_provider.py │ │ ├── test_kimi_provider.py │ │ ├── test_ollama_manager_timeout.py │ │ ├── test_ollama_provider.py │ │ ├── test_openai_provider.py │ │ ├── test_openai_stream_toolcall_compat.py │ │ └── test_provider_manager.py │ └── workspace/ │ ├── __init__.py │ ├── test_agent_creation.py │ ├── test_agent_id.py │ ├── test_agent_model.py │ ├── test_cli_agent_id.py │ ├── test_prompt.py │ └── test_workspace.py └── website/ ├── README.md ├── index.html ├── package.json ├── public/ │ ├── docs/ │ │ ├── channels.en.md │ │ ├── channels.zh.md │ │ ├── cli.en.md │ │ ├── cli.zh.md │ │ ├── commands.en.md │ │ ├── commands.zh.md │ │ ├── community.en.md │ │ ├── community.zh.md │ │ ├── comparison.en.md │ │ ├── comparison.zh.md │ │ ├── config.en.md │ │ ├── config.zh.md │ │ ├── console.en.md │ │ ├── console.zh.md │ │ ├── context.en.md │ │ ├── context.zh.md │ │ ├── contributing.en.md │ │ ├── contributing.zh.md │ │ ├── desktop.en.md │ │ ├── desktop.zh.md │ │ ├── faq.en.md │ │ ├── faq.zh.md │ │ ├── heartbeat.en.md │ │ ├── heartbeat.zh.md │ │ ├── intro.en.md │ │ ├── intro.zh.md │ │ ├── mcp.en.md │ │ ├── mcp.zh.md │ │ ├── memory.en.md │ │ ├── memory.zh.md │ │ ├── models.en.md │ │ ├── models.zh.md │ │ ├── multi-agent.en.md │ │ ├── multi-agent.zh.md │ │ ├── quickstart.en.md │ │ ├── quickstart.zh.md │ │ ├── roadmap.en.md │ │ ├── roadmap.zh.md │ │ ├── security.en.md │ │ ├── security.zh.md │ │ ├── skills.en.md │ │ └── skills.zh.md │ ├── release-notes/ │ │ ├── v0.0.4.md │ │ ├── v0.0.4.zh.md │ │ ├── v0.0.5-beta.1.md │ │ ├── v0.0.5-beta.1.zh.md │ │ ├── v0.0.5-beta.2.md │ │ ├── v0.0.5-beta.2.zh.md │ │ ├── v0.0.5-beta.3.md │ │ ├── v0.0.5-beta.3.zh.md │ │ ├── v0.0.5.md │ │ ├── v0.0.5.zh.md │ │ ├── v0.0.6.md │ │ ├── v0.0.6.zh.md │ │ ├── v0.0.7.md │ │ ├── v0.0.7.zh.md │ │ ├── v0.1.0.md │ │ └── v0.1.0.zh.md │ └── site.config.json ├── scripts/ │ ├── build-search-index.mjs │ └── spa-fallback-pages.mjs ├── src/ │ ├── App.tsx │ ├── components/ │ │ ├── BrandStory.tsx │ │ ├── CatPawIcon.tsx │ │ ├── CopawLogo.tsx │ │ ├── CopawMascot.tsx │ │ ├── DocSearch.tsx │ │ ├── DocSearchResults.tsx │ │ ├── Ecosystem.tsx │ │ ├── Features.tsx │ │ ├── FollowUs.tsx │ │ ├── Footer.tsx │ │ ├── Hero.tsx │ │ ├── MermaidBlock.tsx │ │ ├── Nav.tsx │ │ ├── QuickStart.tsx │ │ ├── Testimonials.tsx │ │ └── UseCases.tsx │ ├── config.ts │ ├── data/ │ │ └── testimonials.ts │ ├── i18n.ts │ ├── index.css │ ├── lib/ │ │ └── docsSearch.ts │ ├── main.tsx │ ├── pages/ │ │ ├── Docs.tsx │ │ ├── Home.tsx │ │ └── ReleaseNotes.tsx │ └── vite-env.d.ts ├── tsconfig.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ # Git and IDE .git .gitignore .idea .vscode *.md !README.md # Python dev and cache __pycache__ *.py[cod] *$py.class .venv venv uv.lock .pytest_cache .coverage htmlcov .tox .mypy_cache .ruff_cache # Tests (not needed in runtime image) tests test.py *_test.py pytest.ini .pre-commit-config.yaml .flake8 .eslintrc .stylelintrc # Frontend: exclude build artifacts; ship pre-built src/console/dist in image website console/node_modules console/dist console/.vite node_modules **/node_modules **/dist !src/copaw/console/dist !src/copaw/console/dist/** **/.vite # Example and local config (mount at runtime instead) example config.json jobs.json sessions_mount_dir # Misc .env .env.* !.env.example .DS_Store *.log logs cookbook ================================================ FILE: .flake8 ================================================ [flake8] exclude = scripts/* src/agentscope/rpc/* max-line-length = 79 inline-quotes = " avoid-escape = no ignore = F401 F403 W503 E731 ================================================ FILE: .gitattributes ================================================ *.bat text eol=crlf ================================================ FILE: .github/ISSUE_TEMPLATE/1-question.md ================================================ --- name: Question / Discussion about: Ask a question or start a discussion (consider GitHub Discussions for open-ended topics) title: "[Question]: " labels: ["question", "triage"] assignees: [] --- ## Question or topic [What would you like to ask or discuss?] ## Context [Relevant setup: CoPaw version, channel, skill, or use case. This helps others answer.] ## Tried so far [Optional: what you already tried or read (docs, issues, etc.).] --- **Note:** For general questions or ideas, [GitHub Discussions](https://github.com/agentscope-ai/CoPaw/discussions) may get more visibility. Use this template when the answer might lead to a bug report, feature request, or doc change. ================================================ FILE: .github/ISSUE_TEMPLATE/2-feature_request.md ================================================ --- name: Feature Request about: Suggest a new feature or enhancement title: "[Feature]: " labels: ["enhancement", "triage"] assignees: [] --- ## Summary [One or two sentences: what do you want and why?] ## Component(s) Affected - [ ] Core / Backend (app, agents, config, providers, utils, local_models) - [ ] Console (frontend web UI) - [ ] Channels (DingTalk, Feishu, QQ, Discord, iMessage, etc.) - [ ] Skills - [ ] CLI - [ ] Documentation (website) - [ ] Tests - [ ] CI/CD - [ ] Scripts / Deploy ## Problem / Motivation [What problem does this solve? Who benefits?] ## Proposed Solution [Describe the feature or change you have in mind. Be as specific as possible.] ## Alternatives Considered [Any other approaches or workarounds you thought about.] ## Additional Context [Screenshots, examples, links to docs or similar features elsewhere.] ## Willing to Contribute - [ ] I am willing to open a PR for this feature (after discussion). ================================================ FILE: .github/ISSUE_TEMPLATE/3-documentation.md ================================================ --- name: Documentation about: Report docs issues or suggest documentation improvements title: "[Docs]: " labels: ["documentation", "triage"] assignees: [] --- ## Summary [What is wrong or missing in the docs? One or two sentences.] **Docs location:** [e.g. website, README, `website/public/docs/quickstart.en.md`, Console UI copy] ## Type - [ ] Typo / wording fix - [ ] Outdated or incorrect content - [ ] Missing section or topic - [ ] Broken link or image - [ ] Translation (zh/en) - [ ] Other ## Details [Describe the issue or the change you suggest. If possible, point to the exact file or URL.] **Current (if applicable):** [Quote or describe current text.] **Suggested:** [Proposed text or structure.] ## Additional Context [Optional: related issues, screenshots, which audience this affects.] ================================================ FILE: .github/ISSUE_TEMPLATE/4-bug_report.md ================================================ --- name: Bug Report about: Report a bug or unexpected behavior title: "[Bug]: " labels: ["bug", "triage"] assignees: [] --- ## CoPaw Version [Provide the version of CoPaw you are using, e.g. 0.x.x or git commit hash.] [Using `copaw --version` in your command line or checking the version in the console UI can help.] ## Description [Describe the bug clearly and concisely. What happened vs what you expected?] **Related PR(s):** #(optional) **Security considerations:** [If applicable, e.g. auth, env/config exposure] ## Component(s) Affected - [ ] Core / Backend (app, agents, config, providers, utils, local_models) - [ ] Console (frontend web UI) - [ ] Channels (DingTalk, Feishu, QQ, Discord, iMessage, etc.) - [ ] Skills - [ ] CLI - [ ] Documentation (website) - [ ] Tests - [ ] CI/CD - [ ] Scripts / Deploy ## Environment - **CoPaw version:** [e.g. 0.x.x or git commit] - **OS:** [e.g. macOS 14, Ubuntu 22.04, Windows 11] - **Install method:** [pip / one-line install / Docker / from source] - **Python version (if applicable):** [e.g. 3.10] ## Steps to Reproduce 1. 2. 3. ## Actual vs Expected - **Actual:** - **Expected:** ## Logs / Screenshots [Paste relevant log output or attach screenshots. Use code blocks for logs.] ``` (paste logs here) ``` ## Additional Notes [Optional: workarounds, similar issues, etc.] ================================================ FILE: .github/ISSUE_TEMPLATE/5-support_environment.md ================================================ --- name: Support / Environment description: Issues with install, Docker, platform, or runtime environment title: "[Support]: " labels: ["support", "triage"] assignees: [] --- ## Summary [What went wrong during install, run, or in your environment?] ## Environment - **OS:** [e.g. macOS 14 (Apple Silicon / Intel), Ubuntu 22.04, Windows 11] - **Install method:** [pip / one-line install (install.sh or install.ps1) / Docker / from source / ModelScope] - **CoPaw version:** [e.g. 0.x.x or commit] - **Python version (if applicable):** [e.g. 3.10, 3.12] ## Steps you took 1. 2. 3. ## What happened [Error message, log excerpt, or behavior. Paste relevant output in code blocks.] ``` (paste here) ``` ## Expected [What you expected to happen.] ## Additional context [Optional: conda/venv, WSL, proxy, firewall, or other env details.] ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ # Issue template chooser. Templates are listed alphanumerically. # See: https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/configuring-issue-templates-for-your-repository blank_issues_enabled: true contact_links: - name: GitHub Discussions url: https://github.com/agentscope-ai/CoPaw/discussions about: Ask questions, share ideas, or discuss CoPaw here. - name: View docs url: https://copaw.agentscope.io/ about: CoPaw docs and guides. ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## Description [Describe what this PR does and why] **Related Issue:** Fixes #(issue_number) or Relates to #(issue_number) **Security Considerations:** [If applicable, e.g. channel auth, env/config handling] ## Type of Change - [ ] Bug fix - [ ] New feature - [ ] Breaking change - [ ] Documentation - [ ] Refactoring ## Component(s) Affected - [ ] Core / Backend (app, agents, config, providers, utils, local_models) - [ ] Console (frontend web UI) - [ ] Channels (DingTalk, Feishu, QQ, Discord, iMessage, etc.) - [ ] Skills - [ ] CLI - [ ] Documentation (website) - [ ] Tests - [ ] CI/CD - [ ] Scripts / Deploy ## Checklist - [ ] I ran `pre-commit run --all-files` locally and it passes - [ ] If pre-commit auto-fixed files, I committed those changes and reran checks - [ ] I ran tests locally (`pytest` or as relevant) and they pass - [ ] Documentation updated (if needed) - [ ] Ready for review ## Testing [How to test these changes] ## Local Verification Evidence ```bash pre-commit run --all-files # paste summary result pytest # paste summary result ``` ## Additional Notes [Optional: any other context] ================================================ FILE: .github/condarc ================================================ # Explicit channels so conda does not warn about implicit 'defaults' channels: - defaults ================================================ FILE: .github/workflows/deploy-website.yml ================================================ # Deploy website to GitHub Pages (see scripts/website_build.sh). # Custom domain: copaw.agentscope.io (CNAME). name: Deploy website to GitHub Pages on: release: types: [published] workflow_dispatch: permissions: contents: write concurrency: group: deploy-website cancel-in-progress: false jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "20" cache: "pnpm" cache-dependency-path: website/pnpm-lock.yaml - name: Install dependencies working-directory: website run: pnpm install --frozen-lockfile - name: Build working-directory: website env: VITE_BASE_PATH: "/" run: pnpm run build # Copy installation scripts so they are served from the website # (e.g. copaw.agentscope.io/install.sh) instead of raw.githubusercontent.com, # which may be blocked by some firewalls. - name: Copy installation scripts to dist run: cp scripts/install.sh scripts/install.ps1 scripts/install.bat website/dist/ # 404.html fallback for unknown paths (real routes from build script). - name: 404 fallback for GitHub Pages run: cp website/dist/index.html website/dist/404.html - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: website/dist cname: copaw.agentscope.io ================================================ FILE: .github/workflows/desktop-release.yml ================================================ # Build CoPaw Desktop: Windows (conda-pack + NSIS), macOS (conda-pack -> .app) # Runs automatically on release publish; also manually via Actions tab. name: CoPaw Desktop Build on: release: types: [published] workflow_dispatch: permissions: contents: read jobs: build-windows: runs-on: windows-latest steps: - name: Checkout uses: actions/checkout@v4 with: submodules: recursive - name: Get version id: version shell: pwsh run: | $content = Get-Content src/copaw/__version__.py -Raw if ($content -match '__version__\s*=\s*"([^"]+)"') { $v = $Matches[1] } else { throw "Failed to extract version from __version__.py" } "version<> $GITHUB_OUTPUT - name: Set up Node uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" cache-dependency-path: console/package-lock.json - name: Set up Miniconda uses: conda-incubator/setup-miniconda@v3 with: auto-update-conda: true python-version: "3.10" activate-environment: "copaw-build" condarc-file: .github/condarc conda-remove-defaults: false - name: Clean dist directory run: rm -rf dist && mkdir -p dist - name: Build macOS .app run: | chmod +x scripts/pack/build_macos.sh conda run -n copaw-build bash -c 'CREATE_ZIP=1 ./scripts/pack/build_macos.sh' - name: Upload macOS artifact uses: actions/upload-artifact@v4 with: name: CoPaw-Desktop-macOS-${{ steps.version.outputs.version }} path: dist/CoPaw-*.zip upload-release: needs: [build-windows, build-macos] if: github.event_name == 'release' runs-on: ubuntu-latest permissions: contents: write steps: - name: Download all artifacts uses: actions/download-artifact@v4 - name: Move artifacts to root for upload run: | mv CoPaw-Desktop-Windows-*/CoPaw-Setup-*.exe . 2>/dev/null || true mv CoPaw-Desktop-macOS-*/CoPaw-*.zip . 2>/dev/null || true - name: Upload to Release uses: softprops/action-gh-release@v2 with: files: | CoPaw-Setup-*.exe CoPaw-*-macOS.zip fail-on-unmatched-files: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/docker-release.yml ================================================ # Build CoPaw multi-arch Docker image and push to DockerHub + Aliyun ACR on release. # Pre-release: update and pre only. # Formal release: update , pre and latest. name: Docker Build and Push on Release on: release: types: [published] workflow_dispatch: inputs: version: description: "Image tag (e.g. v1.2.3 or v1.2.3-beta.1)" required: true type: string is_prerelease: description: "Pre-release (do not update latest tag)" required: false type: boolean default: false env: ACR_REGISTRY: agentscope-registry.ap-southeast-1.cr.aliyuncs.com IMAGE: agentscope/copaw jobs: build-and-push: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: submodules: recursive - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to DockerHub uses: docker/login-action@v3 with: registry: docker.io username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to Aliyun ACR uses: docker/login-action@v3 with: registry: ${{ env.ACR_REGISTRY }} username: ${{ secrets.ALIYUN_ACR_USERNAME }} password: ${{ secrets.ALIYUN_ACR_PASSWORD }} - name: Set version and prerelease flag id: vars run: | if [ -n "${{ github.event.release.tag_name }}" ]; then VERSION="${{ github.event.release.tag_name }}" echo "version=${VERSION}" >> $GITHUB_OUTPUT # Parse version to determine if it's a pre-release # Beta/alpha/rc/dev are pre-releases, but .post is NOT if [[ "$VERSION" =~ (beta|alpha|rc|dev) ]]; then echo "is_prerelease=true" >> $GITHUB_OUTPUT elif [[ "$VERSION" =~ post ]]; then # .post versions should update latest (post-release patches) echo "is_prerelease=false" >> $GITHUB_OUTPUT else # Use GitHub's prerelease flag as fallback echo "is_prerelease=${{ github.event.release.prerelease }}" >> $GITHUB_OUTPUT fi else echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT echo "is_prerelease=${{ github.event.inputs.is_prerelease }}" >> $GITHUB_OUTPUT fi - name: Build and push multi-arch (version + pre [+ latest]) env: VERSION: ${{ steps.vars.outputs.version }} IS_PRERELEASE: ${{ steps.vars.outputs.is_prerelease }} COPAW_DISABLED_CHANNELS: "imessage" run: | TAGS="-t ${ACR_REGISTRY}/${IMAGE}:${VERSION} -t ${ACR_REGISTRY}/${IMAGE}:pre" TAGS="${TAGS} -t docker.io/${IMAGE}:${VERSION} -t docker.io/${IMAGE}:pre" if [ "${IS_PRERELEASE}" != "true" ]; then TAGS="${TAGS} -t ${ACR_REGISTRY}/${IMAGE}:latest -t docker.io/${IMAGE}:latest" fi docker buildx build --platform linux/amd64,linux/arm64 \ -f deploy/Dockerfile \ --build-arg COPAW_DISABLED_CHANNELS="${COPAW_DISABLED_CHANNELS}" \ ${TAGS} --push . ================================================ FILE: .github/workflows/first-time-contributor-welcome.yml ================================================ name: First-Time Contributor Welcome on: pull_request_target: types: [closed] jobs: welcome-comment: if: >- github.event.pull_request.merged == true && contains(toJSON(github.event.pull_request.labels.*.name), 'first-time-contributor') runs-on: ubuntu-latest permissions: pull-requests: write steps: - name: Post welcome comment uses: actions/github-script@v7 with: script: | const author = context.payload.pull_request.user.login; const prNumber = context.payload.pull_request.number; const body = [ `## Welcome to CoPaw! :tada:`, ``, `Thank you @${author} for your first contribution! Your PR has been merged. :rocket:`, ``, `We'd love to give you a shout-out in our release notes! If you're comfortable sharing, ` + `please reply to this comment with your social media handles using the format below:`, ``, '```', `discord: your_discord_handle`, `x: your_x_handle`, `xiaohongshu: your_xiaohongshu_id`, '```', ``, `> **Note:** Only share what you're comfortable with — all fields are optional.`, ``, `Thanks again for helping make CoPaw better!`, ].join('\n'); await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, body, }); core.info(`Posted welcome comment on PR #${prNumber} for @${author}`); ================================================ FILE: .github/workflows/npm-format.yml ================================================ name: NPM Format (website & console) on: [push, pull_request] jobs: website: name: website format check runs-on: ubuntu-latest defaults: run: working-directory: website steps: - uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "20" cache: "pnpm" cache-dependency-path: website/pnpm-lock.yaml - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run format check run: pnpm run format:check console: name: console format check runs-on: ubuntu-latest defaults: run: working-directory: console steps: - uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" cache-dependency-path: console/package-lock.json - name: Install dependencies run: npm ci - name: Run format check run: npm run format:check ================================================ FILE: .github/workflows/pr-label.yml ================================================ name: PR Labeler on: pull_request_target: types: [opened] jobs: first-time-contributor: runs-on: ubuntu-latest permissions: contents: read pull-requests: write steps: - name: Check if first-time contributor uses: actions/github-script@v7 with: script: | const author = context.payload.pull_request.user.login; let isFirstTime = false; try { const { data: searchResult } = await github.rest.search.issuesAndPullRequests({ q: `repo:${context.repo.owner}/${context.repo.repo} type:pr author:${author} is:closed`, }); isFirstTime = searchResult.total_count === 0; if (!isFirstTime) { core.info(`${author} has ${searchResult.total_count} closed PRs, skipping label`); } } catch (err) { if (err.status === 422 && err.message && err.message.includes('cannot be searched')) { core.warning(`Search API cannot look up author ${author}, skipping first-time label`); return; } throw err; } if (isFirstTime) { await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.pull_request.number, labels: ['first-time-contributor'], }); core.info(`Labeled PR #${context.payload.pull_request.number} as first-time-contributor`); } ================================================ FILE: .github/workflows/pre-commit.yml ================================================ name: Pre-commit Checks on: [push, pull_request] jobs: run: runs-on: ubuntu-latest strategy: fail-fast: true env: OS: ubuntu-latest PYTHON: "3.10" steps: - uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v5 with: python-version: "3.10" - name: Update setuptools and wheel run: | pip install setuptools==68.2.2 wheel==0.41.2 - name: Install CoPaw with dev dependencies run: | pip install -q -e ".[dev]" - name: Install pre-commit hooks run: | pre-commit install - name: Run pre-commit run: | pre-commit run --all-files > pre-commit.log 2>&1 || true cat pre-commit.log if grep -q Failed pre-commit.log; then echo -e "\e[41m [**FAIL**] pre-commit checks failed. Run 'pre-commit run --all-files' locally, commit any auto-fixes, then push again. \e[0m" exit 1 fi echo -e "\e[46m ********************************Passed******************************** \e[0m" ================================================ FILE: .github/workflows/publish-pypi.yml ================================================ # Publish Python package to PyPI when a release is created. # Build includes console frontend (see scripts/wheel_build.sh). name: Publish Python Package to PyPI on: workflow_dispatch: release: types: [published] permissions: contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.10" - name: Set up Node (for console build) uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" cache-dependency-path: console/package-lock.json - name: Build console frontend run: | cd console && npm ci && npm run build - name: Copy console build into package run: | rm -rf src/copaw/console/* mkdir -p src/copaw/console cp -R console/dist/* src/copaw/console/ - name: Install build dependencies run: | python -m pip install --upgrade pip pip install setuptools wheel build - name: Build package run: python -m build - name: Publish package to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} ================================================ FILE: .github/workflows/tests.yml ================================================ name: Tests on: push: branches: [main, master, dev, develop] paths: - 'src/**' - 'tests/**' - 'pyproject.toml' - 'setup.py' - '.github/workflows/tests.yml' pull_request: branches: [main, master, dev, develop] paths: - 'src/**' - 'tests/**' - 'pyproject.toml' - 'setup.py' - '.github/workflows/tests.yml' workflow_dispatch: jobs: approval-gate: name: Maintainer Approval runs-on: ubuntu-latest environment: maintainer-approved steps: - name: Approval granted run: echo "Approved by maintainer" unit-tests: name: Unit Tests - py${{ matrix.python-version }} - ${{ matrix.os }} needs: approval-gate if: | always() && needs.approval-gate.result == 'success' runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: python-version: ["3.10", "3.13"] os: [ubuntu-latest] include: - os: macos-latest python-version: "3.10" - os: windows-latest python-version: "3.10" steps: - uses: actions/checkout@v4 - name: Set up Node.js (for console build) uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' cache-dependency-path: console/package-lock.json - name: Build console frontend shell: bash run: | cd console && npm ci && npm run build - name: Copy console build into package shell: bash run: | rm -rf src/copaw/console/* mkdir -p src/copaw/console cp -R console/dist/* src/copaw/console/ - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' - name: Install dependencies shell: bash run: | python -m pip install --upgrade pip if [[ "${{ matrix.os }}" == "macos-latest" ]]; then pip install -e ".[dev,local,ollama]" pip install 'mlx-lm>=0.10.0' || echo "mlx-lm install skipped" pip install --only-binary=llama-cpp-python 'llama-cpp-python>=0.3.0' || \ echo "⚠️ llama-cpp-python prebuilt wheel not available, skipping" else pip install -e ".[dev,full]" fi - name: Run all unit tests shell: bash run: | pytest tests/unit -v integrated-tests: name: Integrated Tests - py${{ matrix.python-version }} - ${{ matrix.os }} needs: approval-gate if: | always() && needs.approval-gate.result == 'success' runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: python-version: ["3.10", "3.13"] os: [ubuntu-latest] include: - os: macos-latest python-version: "3.10" - os: windows-latest python-version: "3.10" steps: - uses: actions/checkout@v4 - name: Set up Node.js (for console build) uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' cache-dependency-path: console/package-lock.json - name: Build console frontend shell: bash run: | cd console && npm ci && npm run build - name: Copy console build into package shell: bash run: | rm -rf src/copaw/console/* mkdir -p src/copaw/console cp -R console/dist/* src/copaw/console/ - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' - name: Install dependencies shell: bash run: | python -m pip install --upgrade pip if [[ "${{ matrix.os }}" == "macos-latest" ]]; then pip install -e ".[dev,local,ollama]" pip install 'mlx-lm>=0.10.0' || echo "mlx-lm install skipped" pip install --only-binary=llama-cpp-python 'llama-cpp-python>=0.3.0' || \ echo "⚠️ llama-cpp-python prebuilt wheel not available, skipping" else pip install -e ".[dev,full]" fi - name: Check if integrated tests exist id: check-integrated shell: bash run: | if [ -d "tests/integrated" ] && compgen -G "tests/integrated/*.py" > /dev/null; then echo "has_tests=true" >> "$GITHUB_OUTPUT" else echo "has_tests=false" >> "$GITHUB_OUTPUT" fi - name: Run integrated tests if: steps.check-integrated.outputs.has_tests == 'true' shell: bash run: | pytest tests/integrated -v coverage-report: name: Coverage Report needs: approval-gate if: | always() && needs.approval-gate.result == 'success' runs-on: ubuntu-latest permissions: contents: read pull-requests: write steps: - uses: actions/checkout@v4 - name: Set up Node.js (for console build) uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' cache-dependency-path: console/package-lock.json - name: Build console frontend shell: bash run: | cd console && npm ci && npm run build - name: Copy console build into package shell: bash run: | rm -rf src/copaw/console/* mkdir -p src/copaw/console cp -R console/dist/* src/copaw/console/ - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: "3.12" cache: 'pip' - name: Install dependencies shell: bash run: | python -m pip install --upgrade pip pip install -e ".[dev,full]" - name: Run all tests with coverage shell: bash run: | pytest tests/ \ -v \ --cov=src/copaw \ --cov-report=xml \ --cov-report=term-missing \ --cov-report=html - name: Generate coverage comment on PR if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository uses: orgoro/coverage@v3.2 with: coverageFile: coverage.xml token: ${{ github.token }} thresholdAll: 0.0 thresholdNew: 0.0 thresholdModified: 0.0 test-summary: name: Test Summary needs: [approval-gate, unit-tests, integrated-tests, coverage-report] if: always() runs-on: ubuntu-latest steps: - name: Check test results shell: bash run: | echo "Approval gate: ${{ needs.approval-gate.result }}" echo "Unit tests: ${{ needs.unit-tests.result }}" echo "Integrated tests: ${{ needs.integrated-tests.result }}" if [ "${{ needs.approval-gate.result }}" != "success" ]; then echo "❌ Approval not granted" exit 1 fi if [ "${{ needs.unit-tests.result }}" = "failure" ] || \ [ "${{ needs.integrated-tests.result }}" = "failure" ]; then echo "❌ Some tests failed" exit 1 else echo "✅ All tests passed" fi ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /.pnp .pnp.js node_modules/ sessions_mount_dir/ # testing /coverage # cookbook cookbook/_build # production /build dist/ .wheelshim/ # Console frontend build (generated in Docker/CI, do not commit) src/copaw/console/dist/ src/copaw/console/ # frontend (website) website/node_modules/ website/.vite/ website/tsconfig.tsbuildinfo website/dist/ # Search index generated by scripts/build-search-index.mjs (run during build) website/public/search-index.json # misc .env .env.* providers.json envs.json !.env.example !.env.template __pycache__/ *.db *.rdb *.egg-info/ *.zip config.json # IDEs and editors .idea/ .vscode/ *.suo *.ntvs* *.njsproj *.sln *.sw? # Logs npm-debug.log* yarn-debug.log* yarn-error.log* openapi-ts*.log # MacOS .DS_Store # Windows Thumbs.db ehthumbs.db Desktop.ini # Linux *~ # Python *.py[cod] *$py.class uv.lock venv/ .venv/ providers.json # Logs logs/ *.log # vibe coding .claude openspec /AGENTS.md CLAUDE.md *.tmp # Build cache .cache/ # Includes conda_unpack_wheels/ for Windows packaging workaround ================================================ FILE: .pre-commit-config.yaml ================================================ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.3.0 hooks: - id: check-ast exclude: '(?x)(.*/skills/.*|^scripts/pack/)' - id: sort-simple-yaml exclude: '(?x)(.*/skills/.*|^scripts/pack/)' - id: check-yaml exclude: | (?x)^( meta.yaml )$ - id: check-xml exclude: '(?x)(.*/skills/.*|^scripts/pack/)' - id: check-toml - id: check-docstring-first exclude: '(?x)(.*/skills/.*|^scripts/pack/)' - id: check-json exclude: '(?x)(.*/skills/.*|^scripts/pack/)' - id: fix-encoding-pragma exclude: '(?x)(.*/skills/.*|^scripts/pack/)' - id: detect-private-key - id: trailing-whitespace exclude: '(?x)(.*/skills/.*|^scripts/pack/)' - repo: https://github.com/asottile/add-trailing-comma rev: v3.1.0 hooks: - id: add-trailing-comma exclude: '(?x)(.*/skills/.*|^scripts/pack/)' - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.7.0 hooks: - id: mypy exclude: (?x)( pb2\.py$ | grpc\.py$ | ^docs | \.html$ | .*/skills/.* ) args: [ --ignore-missing-imports, --disable-error-code=var-annotated, --disable-error-code=union-attr, --disable-error-code=assignment, --disable-error-code=attr-defined, --disable-error-code=import-untyped, --disable-error-code=truthy-function, --follow-imports=skip, --explicit-package-bases, ] - repo: https://github.com/psf/black rev: 23.3.0 hooks: - id: black args: [ --line-length=79 ] exclude: '(?x)(.*/skills/.*|^scripts/pack/)' - repo: https://github.com/PyCQA/flake8 rev: 6.1.0 hooks: - id: flake8 args: [ "--extend-ignore=E203"] exclude: '(?x)(.*/skills/.*|^scripts/pack/)' - repo: https://github.com/pylint-dev/pylint rev: v3.0.2 hooks: - id: pylint exclude: (?x)( ^docs | pb2\.py$ | grpc\.py$ | \.demo$ | \.md$ | \.html$ | .*/skills/.* ) args: [ --disable=W0511, --disable=W0718, --disable=W0122, --disable=C0103, --disable=R0913, --disable=E0401, --disable=E1101, --disable=C0415, --disable=W0603, --disable=R1705, --disable=R0914, --disable=E0601, --disable=W0602, --disable=W0604, --disable=R0801, --disable=R0902, --disable=R0903, --disable=C0123, --disable=W0231, --disable=W1113, --disable=W0221, --disable=R0401, --disable=W0632, --disable=W0123, --disable=C3001, --disable=W0201, --disable=C0302, --disable=W1203, --disable=C2801, --disable=C0114, # Disable missing module docstring for quick dev --disable=C0115, # Disable missing class docstring for quick dev --disable=C0116, # Disable missing function or method docstring for quick dev ] - repo: https://github.com/pre-commit/mirrors-prettier rev: 'v3.0.0' hooks: - id: prettier additional_dependencies: [ 'prettier@3.0.0' ] files: \.(tsx?)$ exclude: '(?x)(^web/|^console/|/dist/|.*/skills/.*|^scripts/pack/)' ================================================ FILE: .python-version ================================================ 3.10 ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to CoPaw ## Welcome! 🐾 Thank you for your interest in contributing to CoPaw! CoPaw is an open-source **personal AI assistant** that runs in your own environment—on your machine or in the cloud. It connects to DingTalk, Feishu, QQ, Discord, iMessage, and other chat apps, supports scheduled tasks and heartbeat, and extends its capabilities through **Skills**. We warmly welcome contributions that help make CoPaw more useful for everyone: whether you add a new channel, a new model provider, a Skill, improve docs, or fix bugs. **Quick links:** [GitHub](https://github.com/agentscope-ai/CoPaw) · [Docs](https://copaw.agentscope.io/) · [License: Apache 2.0](LICENSE) --- ## How to Contribute To keep collaboration smooth and maintain quality, please follow these guidelines. ### 1. Check Existing Plans and Issues Before starting: - **Check [Open Issues](https://github.com/agentscope-ai/CoPaw/issues)** and any [Projects](https://github.com/agentscope-ai/CoPaw/projects) or roadmap labels. - **If a related issue exists** and is open or unassigned: comment to say you want to work on it to avoid duplicate effort. - **If no related issue exists**: open a new issue describing your proposal. The maintainers will respond and can help align with the project direction. ### 2. Commit Message Format We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification for clear history and tooling. **Format:** ``` (): ``` **Types:** - `feat:` New feature - `fix:` Bug fix - `docs:` Documentation only - `style:` Code style (whitespace, formatting, etc.) - `refactor:` Code change that neither fixes a bug nor adds a feature - `perf:` Performance improvement - `test:` Adding or updating tests - `chore:` Build, tooling, or maintenance **Examples:** ```bash feat(channels): add Telegram channel stub fix(skills): correct SKILL.md front matter parsing docs(readme): update quick start for Docker refactor(providers): simplify custom provider validation test(agents): add tests for skill loading ``` ### 3. Pull Request Title Format PR titles should follow the same convention: **Format:** ` (): ` - Use one of: `feat`, `fix`, `docs`, `test`, `refactor`, `chore`, `perf`, `style`, `build`, `revert`. - **Scope must be lowercase** (letters, numbers, hyphens, underscores only). - Keep the description short and descriptive. **Examples:** ``` feat(models): add custom provider for Azure OpenAI fix(channels): handle empty content_parts in Discord docs(skills): document Skills Hub import ``` ### 4. Code and Quality - **Required local gate (must pass before push/PR):** ```bash pip install -e ".[dev,full]" pre-commit install pre-commit run --all-files pytest ``` - **If pre-commit modifies files:** Commit those changes, then rerun `pre-commit run --all-files` until it passes cleanly. - **CI policy:** Pull requests with failing pre-commit checks are not merge-ready. - **Frontend formatting:** If your changes involve the `console` or `website` directories, run the formatter before committing: ```bash cd console && npm run format cd website && npm run format ``` - **Documentation:** Update docs and README when you add or change user-facing behavior. The docs live under `website/public/docs/`. --- ## Types of Contributions CoPaw is designed to be **extensible**: you can add models, channels, Skills, and more. Below are the main contribution areas we care about. --- ### Adding New Models / Model Providers CoPaw supports **multiple model backends**: cloud APIs (e.g. DashScope, ModelScope), **Ollama**, and local backends (**llama.cpp**, **MLX**). You can contribute in two ways: #### A. Custom provider (user configuration) Users can add **custom providers** via the Console or `providers.json`: any OpenAI-compatible API (e.g. vLLM, SGLang, private endpoints) can be configured with a unique ID, base URL, API key, and optional model list. No code change is required for standard OpenAI-compatible APIs. #### B. New built-in provider or new ChatModel (code contribution) If you want to add a **new built-in provider** or a **new API protocol** that is not OpenAI-compatible: 1. **Provider definition** (in `src/copaw/providers/registry.py` or equivalent): - Add a `ProviderDefinition` with `id`, `name`, `default_base_url`, `api_key_prefix`, and optionally `models` and `chat_model`. - For local/self-hosted backends, set `is_local` as appropriate. 2. **Chat model class** (if the API is not OpenAI-compatible): - Implement a class inheriting from `agentscope.model.ChatModelBase` (or CoPaw’s local/remote wrappers where applicable). - Support streaming and non-streaming if the agent uses both; respect `tool_choice` and tools API if used. - Register the class in the registry’s chat model map so the runtime can resolve it by name (see `_CHAT_MODEL_MAP` in `src/copaw/providers/registry.py`). 3. **Documentation:** Document the new provider or model in the docs (e.g. under a “Models” or “Providers” section) and mention any env vars or config keys. Adding a fully new API (new message format, token counting, tools) is a larger change; we recommend opening an issue first to discuss scope and design. --- ### Adding New Channels Channels are how CoPaw talks to **DingTalk, Feishu, QQ, Discord, iMessage**, etc. You can add a new channel so CoPaw can work with your favorite IM or bot platform. - **Protocol:** All channels use a unified in-process contract: **native payload → `content_parts`** (e.g. `TextContent`, `ImageContent`, `FileContent`). The agent receives `AgentRequest` with these content parts; replies are sent back via the channel’s send path. - **Implementation:** Implement a **subclass of `BaseChannel`** (in `src/copaw/app/channels/base.py`): - Set the class attribute `channel` to a unique channel key (e.g. `"telegram"`). - Implement the lifecycle and message handling (e.g. receive → `content_parts` → `process` → send response). - Use the manager’s queue and consumer loop if the channel is long-lived (default). - **Discovery:** Built-in channels are registered in `src/copaw/app/channels/registry.py`. **Custom channels** are loaded from the working directory: place a module (e.g. `custom_channels/telegram.py` or a package `custom_channels/telegram/`) that defines a `BaseChannel` subclass with a `channel` attribute. - **CLI:** Users install/add channels with: - `copaw channels install ` — create a template or copy from `--path` / `--url` - `copaw channels add ` — install and add to config - `copaw channels remove ` — remove custom channel from `custom_channels/` - `copaw channels config` — interactive config If you contribute a **new built-in channel**, add it to the registry and, if needed, a configurator so it appears in the Console and CLI. Document the new channel (auth, webhooks, etc.) in `website/public/docs/channels.*.md`. --- ### Adding Base Skills **Skills** define what CoPaw can do: cron, file reading, PDF/Office, news, browser, etc. We welcome **broadly useful** base skills (productivity, documents, communication, automation) that fit the majority of users. - **Structure:** Each skill is a **directory** containing: - **`SKILL.md`** — Markdown instructions for the agent. Use YAML front matter for at least `name` and `description`; optional `metadata` (e.g. for Console). - **`references/`** (optional) — Reference documents the agent can use. - **`scripts/`** (optional) — Scripts or tools the skill uses. - **Location:** Built-in skills live under `src/copaw/agents/skills//`. The app merges built-in and user **customized_skills** from the working dir into **active_skills**; no extra registration is needed beyond placing a valid `SKILL.md` in a directory. - **Content:** Write clear, task-oriented instructions. Describe **when** the skill should be used and **how** (steps, commands, file formats). Avoid overly niche or personal workflows if targeting the **base** repository; those are great as custom or community Skills. - **Skills Hub:** CoPaw supports importing skills from a community hub (e.g. ClawHub). If you want your skill to be installable via hub, follow the same `SKILL.md` + `references/`/`scripts/` layout and the hub’s packaging format. Examples of in-repo base skills: **cron**, **file_reader**, **news**, **pdf**, **docx**, **pptx**, **xlsx**, **browser_visible**. Contributing a new base skill usually means: add the directory under `agents/skills/`, add a short entry in the docs (e.g. Skills table in `website/public/docs/skills.*.md`), and ensure it syncs correctly to the working directory. #### Writing Effective Skill Descriptions To help the model accurately recognize and invoke your skill, the `description` field in your SKILL.md front matter must be **clear, specific, and include trigger keywords**. Follow these best practices: **✅ Recommended format:** ```yaml --- name: example_skill description: "Use this skill whenever user wants to [main functionality]. Trigger especially when user mentions: [trigger keywords]. Also use when [other scenarios]." # Detailed instructions below ... ``` **✅ Best practices:** 1. **Clearly state when to trigger**: Use phrases like "Use this skill whenever user wants to..." or "Trigger when user asks for..." 2. **List trigger keywords explicitly**: Make it easy for the model to recognize, for example: - "Trigger especially when user mentions: \"call\", \"dial\", \"phone\", \"microsip\"" - "Also trigger for desktop automation tasks like opening apps, controlling windows" 3. **Be specific about the skill's scope**: Say exactly what it does, avoid vague terms - ✅ Good: "Make phone calls via MicroSIP or similar desktop apps" - ❌ Not ideal: "Control desktop" 4. **Provide usage examples**: If the skill has specific usage patterns, explain them in the body of SKILL.md **❌ Common pitfalls:** - Overly abstract descriptions (like "control desktop", "process files") - Missing trigger keywords, making it hard for the model to identify use cases - Lack of usage scenario context **📝 Examples comparison:** | Skill | Description (Not ideal) | Description (Better) | |-------|-------------------------|----------------------| | Desktop Control | "Control desktop applications" | "Use this skill whenever user wants to control desktop applications or make phone calls. Trigger especially when user mentions: \"call\", \"dial\", \"phone\", \"microsip\", or requests to use specific desktop apps." | | File Reader | "Read files" | "Use this skill when user asks to read or summarize local text-based files. PDFs, Office documents, images are out of scope." | --- ### Platform support (Windows, Linux, macOS, etc.) CoPaw aims to run on **Windows**, **Linux**, and **macOS**. Contributions that improve support on a specific platform are welcome. - **Compatibility fixes:** Path handling, line endings, shell commands, or dependencies that behave differently per OS. For example: Windows compatibility for the memory/vector stack, or install scripts that work on both Linux and macOS. - **Install and run:** One-line install (`install.sh`), `pip` install, and `copaw init` / `copaw app` should work (or be clearly documented) on each supported platform. Fixes to install or startup on a given OS are valuable. - **Platform-specific features:** Optional integrations (e.g. notifying only when supported) are fine as long as they don’t break other platforms. Use runtime checks or optional dependencies where appropriate. - **Documentation:** Document any platform-specific steps, known limitations, or recommended setups (e.g. WSL on Windows, Apple Silicon vs x86) in the docs or README. If you add or change platform support, please test on the affected OS and mention it in the PR description. Opening an issue first is recommended for larger or ambiguous platform work. --- ### Other Contributions - **MCP (Model Context Protocol):** CoPaw supports runtime **MCP tool** discovery and hot-plug. Contributing new MCP servers or tools (or docs on how to attach them) helps users extend the agent without changing core code. - **Documentation:** Fixes and improvements to [the docs](https://copaw.agentscope.io/) (under `website/public/docs/`) and README are always welcome. - **Bug fixes and refactors:** Small fixes, clearer error messages, and refactors that keep behavior the same are valuable. Prefer opening an issue for larger refactors so we can align on approach. - **Examples and workflows:** Tutorials or example workflows (e.g. “daily digest to DingTalk”, “local model + cron”) can be documented or linked from the repo/docs. - **Any other useful things!** --- ## Do's and Don'ts ### ✅ DO - Start with small, focused changes. - Discuss large or design-sensitive changes in an issue first. - Write or update tests where applicable. - Update documentation for user-facing changes. - Use conventional commit messages and PR titles. - Be respectful and constructive (we follow a welcoming Code of Conduct). ### ❌ DON'T - Don’t open very large PRs without prior discussion. - Don’t ignore CI or pre-commit failures. - Don’t mix unrelated changes in one PR. - Don’t break existing APIs without a good reason and clear migration notes. - Don’t add heavy or optional dependencies to the core install without discussing in an issue. --- ## Getting Help - **Discussions:** [GitHub Discussions](https://github.com/agentscope-ai/CoPaw/discussions) - **Bugs and features:** [GitHub Issues](https://github.com/agentscope-ai/CoPaw/issues) - **Community:** DingTalk group (see [README](README.md)) and [Discord](https://discord.gg/eYMpfnkG8h) Thank you for contributing to CoPaw. Your work helps make it a better assistant for everyone. 🐾 ================================================ FILE: CONTRIBUTING_zh.md ================================================ # 为 CoPaw 贡献代码 ## 欢迎!🐾 感谢你对 CoPaw 的关注!CoPaw 是一个开源的**个人 AI 助手**,可以在你自己的环境中运行——无论是你的机器还是云端。它可以连接钉钉、飞书、QQ、Discord、iMessage 等聊天应用,支持定时任务和心跳机制,并通过 **Skills** 扩展其能力。我们热烈欢迎能让 CoPaw 对所有人更有用的贡献:无论是添加新的频道、新的模型提供商、Skill,改进文档,还是修复 bug。 **快速链接:** [GitHub](https://github.com/agentscope-ai/CoPaw) · [文档](https://copaw.agentscope.io/) · [许可证:Apache 2.0](LICENSE) --- ## 如何贡献 为了保持协作顺畅并维护质量,请遵循以下指南。 ### 1. 检查现有计划和问题 在开始之前: - **检查 [Open Issues](https://github.com/agentscope-ai/CoPaw/issues)** 以及任何 [Projects](https://github.com/agentscope-ai/CoPaw/projects) 或路线图标签。 - **如果存在相关 issue** 且处于开放或未分配状态:发表评论表示你想要处理它,以避免重复工作。 - **如果不存在相关 issue**:创建一个新 issue 描述你的提案。维护者会回复并帮助与项目方向对齐。 ### 2. 提交信息格式 我们遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范,以保持清晰的历史记录和工具支持。 **格式:** ``` (): ``` **类型:** - `feat:` 新功能 - `fix:` Bug 修复 - `docs:` 仅文档更改 - `style:` 代码风格(空格、格式等) - `refactor:` 既不修复 bug 也不添加功能的代码更改 - `perf:` 性能改进 - `test:` 添加或更新测试 - `chore:` 构建、工具或维护 **示例:** ```bash feat(channels): add Telegram channel stub fix(skills): correct SKILL.md front matter parsing docs(readme): update quick start for Docker refactor(providers): simplify custom provider validation test(agents): add tests for skill loading ``` ### 3. Pull Request 标题格式 PR 标题应遵循相同的约定: **格式:** ` (): ` - 使用以下之一:`feat`、`fix`、`docs`、`test`、`refactor`、`chore`、`perf`、`style`、`build`、`revert`。 - **scope 必须小写**(仅字母、数字、连字符、下划线)。 - 保持描述简短且描述性强。 **示例:** ``` feat(models): add custom provider for Azure OpenAI fix(channels): handle empty content_parts in Discord docs(skills): document Skills Hub import ``` ### 4. 代码和质量 - **本地必跑门禁(push/提 PR 前必须通过):** ```bash pip install -e ".[dev,full]" pre-commit install pre-commit run --all-files pytest ``` - **如果 pre-commit 自动修改了文件:** 先提交这些修改,再重复执行 `pre-commit run --all-files`,直到无修改且通过。 - **CI 策略:** pre-commit 检查失败的 PR 视为未就绪(not merge-ready)。 - **前端代码格式化:** 如果你的修改涉及到 `console` 或 `website` 目录,请在提交前运行格式化: ```bash cd console && npm run format cd website && npm run format ``` - **文档:** 当你添加或更改面向用户的行为时,更新文档和 README。文档位于 `website/public/docs/` 下。 --- ## 贡献类型 CoPaw 设计为**可扩展的**:你可以添加模型、频道、Skills 等。以下是我们关心的主要贡献领域。 --- ### 添加新模型 / 模型提供商 CoPaw 支持**多种模型后端**:云 API(如 DashScope、ModelScope)、**Ollama** 和本地后端(**llama.cpp**、**MLX**)。你可以通过两种方式贡献: #### A. 自定义提供商(用户配置) 用户可以通过 Console 或 `providers.json` 添加**自定义提供商**:任何 OpenAI 兼容的 API(如 vLLM、SGLang、私有端点)都可以通过唯一 ID、base URL、API key 和可选的模型列表进行配置。标准 OpenAI 兼容 API 无需代码更改。 #### B. 新的内置提供商或新的 ChatModel(代码贡献) 如果你想添加**新的内置提供商**或**不兼容 OpenAI 的新 API 协议**: 1. **提供商定义**(在 `src/copaw/providers/registry.py` 或等效位置): - 添加一个 `ProviderDefinition`,包含 `id`、`name`、`default_base_url`、`api_key_prefix`,以及可选的 `models` 和 `chat_model`。 - 对于本地/自托管后端,根据需要设置 `is_local`。 2. **聊天模型类**(如果 API 不兼容 OpenAI): - 实现一个继承自 `agentscope.model.ChatModelBase` 的类(或适用时使用 CoPaw 的本地/远程包装器)。 - 如果 agent 同时使用流式和非流式,则都要支持;如果使用了 tools API,则遵守 `tool_choice` 和 tools。 - 在注册表的聊天模型映射中注册该类,以便运行时可以按名称解析它(参见 `src/copaw/providers/registry.py` 中的 `_CHAT_MODEL_MAP`)。 3. **文档:** 在文档中记录新的提供商或模型(例如在"模型"或"提供商"部分下),并提及任何环境变量或配置键。 添加全新的 API(新消息格式、token 计数、tools)是较大的更改;我们建议先创建 issue 讨论范围和设计。 --- ### 添加新频道 频道是 CoPaw 与**钉钉、飞书、QQ、Discord、iMessage** 等通信的方式。你可以添加新频道,以便 CoPaw 可以与你喜欢的 IM 或机器人平台配合使用。 - **协议:** 所有频道使用统一的进程内契约:**原生 payload → `content_parts`**(如 `TextContent`、`ImageContent`、`FileContent`)。agent 接收带有这些内容部分的 `AgentRequest`;回复通过频道的发送路径返回。 - **实现:** 实现 **`BaseChannel` 的子类**(在 `src/copaw/app/channels/base.py` 中): - 将类属性 `channel` 设置为唯一的频道键(如 `"telegram"`)。 - 实现生命周期和消息处理(如 receive → `content_parts` → `process` → send response)。 - 如果频道是长期运行的(默认),使用 manager 的队列和消费者循环。 - **发现:** 内置频道在 `src/copaw/app/channels/registry.py` 中注册。**自定义频道**从工作目录加载:放置一个模块(如 `custom_channels/telegram.py` 或包 `custom_channels/telegram/`),定义一个带有 `channel` 属性的 `BaseChannel` 子类。 - **CLI:** 用户使用以下命令安装/添加频道: - `copaw channels install ` — 创建模板或从 `--path` / `--url` 复制 - `copaw channels add ` — 安装并添加到配置 - `copaw channels remove ` — 从 `custom_channels/` 中删除自定义频道 - `copaw channels config` — 交互式配置 如果你贡献**新的内置频道**,将其添加到注册表,如有需要,添加配置器以使其出现在 Console 和 CLI 中。在 `website/public/docs/channels.*.md` 中记录新频道(身份验证、webhooks 等)。 --- ### 添加基础 Skills **Skills** 定义了 CoPaw 可以做什么:cron、文件读取、PDF/Office、新闻、浏览器等。我们欢迎**广泛有用的**基础 skills(生产力、文档、通信、自动化),适合大多数用户。 - **结构:** 每个 skill 是一个**目录**,包含: - **`SKILL.md`** — agent 的 Markdown 指令。使用 YAML front matter 至少包含 `name` 和 `description`;可选的 `metadata`(如用于 Console)。 - **`references/`**(可选)— agent 可以使用的参考文档。 - **`scripts/`**(可选)— skill 使用的脚本或工具。 - **位置:** 内置 skills 位于 `src/copaw/agents/skills//` 下。应用程序将内置和用户的 **customized_skills**(来自工作目录)合并到 **active_skills** 中;除了在目录中放置有效的 `SKILL.md` 外,不需要额外的注册。 - **内容:** 编写清晰的、面向任务的指令。描述**何时**应该使用该 skill 以及**如何**使用(步骤、命令、文件格式)。如果针对**基础**仓库,避免过于小众或个人的工作流程;这些作为自定义或社区 Skills 非常好。 #### 编写有效的 Skill Description 为了让 model 能够准确识别并调用你的 skill,`description` 字段必须**清晰、具体且包含触发词**。请遵循以下最佳实践: **✅ 推荐格式:** ```yaml --- name: example_skill description: "Use this skill whenever user wants to [主要功能]. Trigger especially when user mentions: [触发词列表]. Also use when [其他场景]." # 详细说明 ... ``` **✅ 最佳实践:** 1. **明确触发时机**:使用 "Use this skill whenever user wants to..." 或 "Trigger when user asks for..." 2. **列出触发关键词**:在 description 中明确列出触发词,例如: - "Trigger especially when user mentions: \"call\", \"dial\", \"phone\", \"microsip\"" - "Also trigger for desktop automation tasks like opening apps, controlling windows" 3. **具体描述功能范围**:说明技能做什么,不要含糊 - ✅ 好的:"Make phone calls via MicroSIP or similar desktop apps" - ❌ 不好:"Control desktop" 4. **提供使用示例**:如果技能有特定用法,在 SKILL.md 主体部分说明 **❌ 常见问题:** - 描述过于抽象(如"控制桌面"、"处理文件") - 没有列出触发关键词,导致 model 无法识别 - 缺少使用场景说明 **📝 示例对比:** | 技能 | 描述(不好) | 描述(好) | |------|---------------|-------------| | Desktop Control | "控制桌面应用" | "Use this skill whenever user wants to control desktop applications or make phone calls. Trigger especially when user mentions: \"call\" (呼叫), \"dial\" (拨打), \"phone\" (电话), \"microsip\", or requests to use specific desktop apps." | | File Reader | "读取文件" | "Use this skill when user asks to read or summarize local text-based files. PDFs, Office documents, and images are out of scope." | - **Skills Hub:** CoPaw 支持从社区 hub(如 ClawHub)导入 skills。如果你希望你的 skill 可以通过 hub 安装,请遵循相同的 `SKILL.md` + `references/`/`scripts/` 布局和 hub 的打包格式。 仓库内基础 skills 的示例:**cron**、**file_reader**、**news**、**pdf**、**docx**、**pptx**、**xlsx**、**browser_visible**。贡献新的基础 skill 通常意味着:在 `agents/skills/` 下添加目录,在文档中添加简短条目(如 `website/public/docs/skills.*.md` 中的 Skills 表),并确保它正确同步到工作目录。 --- ### 平台支持(Windows、Linux、macOS 等) CoPaw 旨在在 **Windows**、**Linux** 和 **macOS** 上运行。欢迎改进特定平台支持的贡献。 - **兼容性修复:** 路径处理、行尾、shell 命令或在不同操作系统上行为不同的依赖项。例如:内存/向量栈的 Windows 兼容性,或在 Linux 和 macOS 上都能工作的安装脚本。 - **安装和运行:** 一行安装(`install.sh`)、`pip` 安装,以及 `copaw init` / `copaw app` 应该在每个支持的平台上工作(或有清晰的文档说明)。对给定操作系统上的安装或启动的修复很有价值。 - **平台特定功能:** 可选集成(如仅在支持时通知)是可以的,只要它们不会破坏其他平台。在适当的地方使用运行时检查或可选依赖项。 - **文档:** 在文档或 README 中记录任何平台特定的步骤、已知限制或推荐设置(如 Windows 上的 WSL、Apple Silicon vs x86)。 如果你添加或更改平台支持,请在受影响的操作系统上进行测试,并在 PR 描述中提及。对于较大或模糊的平台工作,建议先创建 issue。 --- ### 其他贡献 - **MCP(模型上下文协议):** CoPaw 支持运行时 **MCP 工具**发现和热插拔。贡献新的 MCP 服务器或工具(或关于如何附加它们的文档)可以帮助用户扩展 agent 而无需更改核心代码。 - **文档:** 对 [文档](https://copaw.agentscope.io/)(位于 `website/public/docs/` 下)和 README 的修复和改进始终受欢迎。 - **Bug 修复和重构:** 小的修复、更清晰的错误消息以及保持行为相同的重构都很有价值。对于较大的重构,最好先创建 issue,以便我们可以就方法达成一致。 - **示例和工作流程:** 教程或示例工作流程(如"每日摘要到钉钉"、"本地模型 + cron")可以记录或从仓库/文档链接。 - **任何其他有用的东西!** --- ## 应该做和不应该做 ### ✅ 应该做 - 从小的、集中的更改开始。 - 在 issue 中首先讨论大型或涉及敏感的更改。 - 在适用的地方编写或更新测试。 - 为面向用户的更改更新文档。 - 使用常规提交消息和 PR 标题。 - 保持尊重和建设性(我们遵循友好的行为准则)。 ### ❌ 不应该做 - 不要在没有事先讨论的情况下打开非常大的 PR。 - 不要忽略 CI 或 pre-commit 失败。 - 不要在一个 PR 中混合不相关的更改。 - 不要在没有充分理由和清晰迁移说明的情况下破坏现有 API。 - 不要在没有在 issue 中讨论的情况下向核心安装添加重型或可选依赖项。 --- ## 获取帮助 - **讨论:** [GitHub Discussions](https://github.com/agentscope-ai/CoPaw/discussions) - **Bug 和功能:** [GitHub Issues](https://github.com/agentscope-ai/CoPaw/issues) - **社区:** 钉钉群(见 [README](README_zh.md))和 [Discord](https://discord.gg/eYMpfnkG8h) 感谢你为 CoPaw 贡献代码。你的工作帮助它成为每个人更好的助手。🐾 ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to the Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS Copyright 2025 The CoPaw Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================
# CoPaw [![GitHub Repo](https://img.shields.io/badge/GitHub-Repo-black.svg?logo=github)](https://github.com/agentscope-ai/CoPaw) [![PyPI](https://img.shields.io/pypi/v/copaw?color=3775A9&label=PyPI&logo=pypi)](https://pypi.org/project/copaw/) [![Documentation](https://img.shields.io/badge/Docs-Website-green.svg?logo=readthedocs&label=Docs)](https://copaw.agentscope.io/) [![Python Version](https://img.shields.io/badge/python-3.10%20~%20%3C3.14-blue.svg?logo=python&label=Python)](https://www.python.org/downloads/) [![Last Commit](https://img.shields.io/github/last-commit/agentscope-ai/CoPaw)](https://github.com/agentscope-ai/CoPaw) [![License](https://img.shields.io/badge/license-Apache%202.0-red.svg?logo=apache&label=License)](LICENSE) [![Code Style](https://img.shields.io/badge/code%20style-black-black.svg?logo=python&label=CodeStyle)](https://github.com/psf/black) [![GitHub Stars](https://img.shields.io/github/stars/agentscope-ai/CoPaw?style=flat&logo=github&color=yellow&label=Stars)](https://github.com/agentscope-ai/CoPaw/stargazers) [![GitHub Forks](https://img.shields.io/github/forks/agentscope-ai/CoPaw?style=flat&logo=github&color=purple&label=Forks)](https://github.com/agentscope-ai/CoPaw/network) [![DeepWiki](https://img.shields.io/badge/DeepWiki-Ask_Devin-navy.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/agentscope-ai/CoPaw) [![Discord](https://img.shields.io/badge/Discord-Join_Us-blueviolet.svg?logo=discord)](https://discord.gg/eYMpfnkG8h) [![X](https://img.shields.io/badge/X-Follow_Us-black.svg?logo=x)](https://x.com/agentscope_ai) [![DingTalk](https://img.shields.io/badge/DingTalk-Join_Us-orange.svg)](https://qr.dingtalk.com/action/joingroup?code=v1,k1,OmDlBXpjW+I2vWjKDsjvI9dhcXjGZi3bQiojOq3dlDw=&_dt_no_comment=1&origin=11) [[Documentation](https://copaw.agentscope.io/)] [[中文 README](README_zh.md)] [[日本語](README_ja.md)]

CoPaw Logo

Works for you, grows with you.

Your Personal AI Assistant; easy to install, deploy on your own machine or on the cloud; supports multiple chat apps with easily extensible capabilities. > **Core capabilities:** > > **Every channel** — DingTalk, Feishu, QQ, Discord, iMessage, and more. One assistant, connect as you need. > > **Under your control** — Memory and personalization under your control. Deploy locally or in the cloud; scheduled reminders to any channel. > > **Skills** — Built-in cron; custom skills in your workspace, auto-loaded. No lock-in. > >
> What you can do > >
> > - **Social**: daily digest of hot posts (Xiaohongshu, Zhihu, Reddit), Bilibili/YouTube summaries. > - **Productivity**: newsletter digests to DingTalk/Feishu/QQ, contacts from email/calendar. > - **Creative**: describe your goal, run overnight, get a draft next day. > - **Research**: track tech/AI news, personal knowledge base. > - **Desktop**: organize files, read/summarize docs, request files in chat. > - **Explore**: combine Skills and cron into your own agentic app. > >
--- ## News [2026-03-18] We released v0.1.0! See the [v0.1.0 Release Notes](https://agentscope-ai.github.io/CoPaw/release-notes) for the full changelog. - **[v0.1.0] Added:** Multi-workspace architecture with agent selector; skill security scanner and destructive shell command detection; optional web authentication; WeCom and XiaoYi channels; DingTalk AI Card replies; Gemini, DeepSeek, MiniMax, and Kimi providers; console dark mode and multimodal chat; SSE-based chat streaming with reconnect; voice message transcription via Whisper; `view_image` tool for multimodal conversations; LobeHub, ModelScope, and zip archive skill import; `glob_search` and `grep_search` built-in tools; timezone selector; `copaw update` CLI. - **[v0.1.0] Improved:** Graceful lifecycle management with zero-downtime agent reload; dynamic per-agent token counting; config loading protection; console internationalization with localized chat prompts; Windows desktop startup speed via bytecode pre-compilation; QQ channel reply logic with DM support. - **[v0.1.0] Fixed:** Telegram thread replies, media handling, and auto-reconnect; Discord cross-channel message merging and debounce generalization; Feishu channel reload; Ollama/LM Studio context length and error messages; cron jobs in correct workspace; Windows cross-disk moves, AutoRun stderr, and GBK encoding. - **[v0.1.0] Contributors:** Thanks to new contributors: [@dipeshbabu](https://github.com/dipeshbabu), [@sljeff](https://github.com/sljeff), [@octo-patch](https://github.com/octo-patch), [@Alexxigang](https://github.com/Alexxigang), [@howyoungchen](https://github.com/howyoungchen), [@nphenix](https://github.com/nphenix), [@skyfaker](https://github.com/skyfaker), [@hh0592821](https://github.com/hh0592821), [@futuremeng](https://github.com/futuremeng), [@toby1123yjh](https://github.com/toby1123yjh), [@hiyuchang](https://github.com/hiyuchang), [@hanson-hex](https://github.com/hanson-hex), [@JackyMao1999](https://github.com/JackyMao1999), [@mvanhorn](https://github.com/mvanhorn), [@yuanxs21](https://github.com/yuanxs21), [@aissac](https://github.com/aissac), [@lcq225](https://github.com/lcq225), [@Justin-lu](https://github.com/Justin-lu), [@rowanchen-com](https://github.com/rowanchen-com), [@pzlav](https://github.com/pzlav), [@mautops](https://github.com/mautops), [@hikariming](https://github.com/hikariming), [@Vanlee0129](https://github.com/Vanlee0129), [@JiwaniZakir](https://github.com/JiwaniZakir), [@EuanTop](https://github.com/EuanTop). [2026-03-12] We released v0.0.7! See the [v0.0.7 Release Notes](https://agentscope-ai.github.io/CoPaw/release-notes) for the full changelog. [2026-03-09] We released v0.0.6! See the [v0.0.6 Release Notes](https://agentscope-ai.github.io/CoPaw/release-notes) for the full changelog. [2026-03-06] We released v0.0.5! See the [v0.0.5 Release Notes](https://agentscope-ai.github.io/CoPaw/release-notes) for the full changelog. [2026-03-02] We released v0.0.4! See the [v0.0.4 Release Notes](https://agentscope-ai.github.io/CoPaw/release-notes) for the full changelog. --- ## Table of Contents > **Recommended reading:** > > - **I want to run CoPaw in 3 commands**: [Quick Start](#quick-start) → open Console in browser. > - **I want to chat in DingTalk / Feishu / QQ**: Configure [channels](https://copaw.agentscope.io/docs/channels) in the Console. > - **I don’t want to install Python**: [Script install](#script-install) handles Python automatically, or use [ModelScope one-click](https://modelscope.cn/studios/fork?target=AgentScope/CoPaw) for cloud deployment. - [News](#news) - [Quick Start](#quick-start) - [API Key](#api-key) - [Local Models](#local-models) - [Documentation](#documentation) - [FAQ](#faq) - [Roadmap](#roadmap) - [Contributing](#get-involved) - [Install from source](#install-from-source) - [Why CoPaw?](#why-copaw) - [Built by](#built-by) - [License](#license) --- ## Quick Start ### pip install If you prefer managing Python yourself: ```bash pip install copaw copaw init --defaults copaw app ``` Then open **http://127.0.0.1:8088/** in your browser for the Console (chat with CoPaw, configure the agent). To talk in DingTalk, Feishu, QQ, etc., add a channel in the [docs](https://copaw.agentscope.io/docs/channels). ![Console](https://img.alicdn.com/imgextra/i3/O1CN01VYsFVo23aAvIM3GXB_!!6000000007271-2-tps-3328-1860.png) ### Script install No Python setup required, one command installs everything. The script will automatically download uv (Python package manager), create a virtual environment, and install CoPaw with all dependencies (including Node.js and frontend assets). Note: May not work in restricted network environments or corporate firewalls. **macOS / Linux:** ```bash curl -fsSL https://copaw.agentscope.io/install.sh | bash ``` To install with Ollama support: ```bash curl -fsSL https://copaw.agentscope.io/install.sh | bash -s -- --extras ollama ``` To install with multiple extras (e.g., Ollama + llama.cpp): ```bash curl -fsSL https://copaw.agentscope.io/install.sh | bash -s -- --extras ollama,llamacpp ``` **Windows (CMD):** ```CMD curl -fsSL https://copaw.agentscope.io/install.bat -o install.bat && install.bat ``` **Windows (PowerShell):** ```powershell irm https://copaw.agentscope.io/install.ps1 | iex ``` > **Note**: The installer will automatically check the status of uv. If it is not installed, it will attempt to download and configure it automatically. If the automatic installation fails, please follow the on-screen prompts or execute `python -m pip install -U uv`, then rerun the installer. > **⚠️ Special Notice for Windows Enterprise LTSC Users** > > If you are using Windows LTSC or an enterprise environment governed by strict security policies, PowerShell may run in **Constrained Language Mode**, potentially causing the following issue: > 1. **If using CMD (.bat): Script executes successfully but fails to write to `Path`** > > The script completes file installation. Due to **Constrained Language Mode**, it cannot automatically update environment variables. Manually configure as follows: > - **Locate the installation directory**: > - Check if `uv` is available: Enter `uv --version` in CMD. If a version number appears, **only configure the CoPaw path**. If you receive the prompt `'uv' is not recognized as an internal or external command, operable program or batch file,` configure both paths. > - uv path (choose one based on installation location; use if `uv` fails): Typically `%USERPROFILE%\.local\bin`, `%USERPROFILE%\AppData\Local\uv`, or the `Scripts` folder within your Python installation directory > - CoPaw path: Typically located at `%USERPROFILE%\.copaw\bin`. > - **Manually add to the system's Path environment variable**: > - Press `Win + R`, type `sysdm.cpl` and press Enter to open System Properties. > - Click “Advanced” -> “Environment Variables”. > - Under “System variables”, locate and select `Path`, then click “Edit”. > - Click “New”, enter both directory paths sequentially, then click OK to save. > 2. **If using PowerShell (.ps1): Script execution interrupted** > > Due to **Constrained Language Mode**, the script may fail to automatically download `uv`. > - **Manually install uv**: Refer to the [GitHub Release](https://github.com/astral-sh/uv/releases) to download `uv.exe` and place it in `%USERPROFILE%\.local\bin` or `%USERPROFILE%\AppData\Local\uv`; or ensure Python is installed and run `python -m pip install -U uv`. > - **Configure `uv` environment variables**: Add the `uv` directory and `%USERPROFILE%\.copaw\bin` to your system's `Path` variable. > - **Re-run the installation**: Open a new terminal and execute the installation script again to complete the `CoPaw` installation. > - **Configure the `CoPaw` environment variable**: Add `%USERPROFILE%\.copaw\bin` to your system's `Path` variable. Once installed, open a new terminal and run: ```bash copaw init --defaults # or: copaw init (interactive) copaw app ```
Install options **macOS / Linux:** ```bash # Install a specific version curl -fsSL ... | bash -s -- --version 0.0.2 # Install from source (dev/testing) curl -fsSL ... | bash -s -- --from-source # With local model support bash install.sh --extras llamacpp # llama.cpp (cross-platform) bash install.sh --extras mlx # MLX (Apple Silicon) bash install.sh --extras llamacpp,mlx # Upgrade — just re-run the installer curl -fsSL ... | bash # Uninstall copaw uninstall # keeps config and data copaw uninstall --purge # removes everything ``` **Windows (PowerShell):** ```powershell # Install a specific version irm ... | iex; .\install.ps1 -Version 0.0.2 # Install from source (dev/testing) .\install.ps1 -FromSource # With local model support .\install.ps1 -Extras llamacpp # llama.cpp (cross-platform) .\install.ps1 -Extras mlx # MLX .\install.ps1 -Extras llamacpp,mlx # Upgrade — just re-run the installer irm ... | iex # Uninstall copaw uninstall # keeps config and data copaw uninstall --purge # removes everything ```
### Desktop Application (Beta) > **Beta Notice**: The desktop application is currently in Beta testing phase with the following known limitations: > - **Incomplete compatibility testing**: Not fully tested across all system versions and hardware configurations > - **Potential performance issues**: Startup time, memory usage, and other performance aspects may need further optimization > - **Features under development**: Some features may be unstable or missing If you're not comfortable with command-line tools, you can download and use CoPaw's desktop application without manually configuring Python environments or running commands. #### Download Download the desktop app from [GitHub Releases](https://github.com/agentscope-ai/CoPaw/releases): - **Windows**: `CoPaw-Setup-.exe` - **macOS**: `CoPaw--macOS.zip` (Apple Silicon recommended) #### Features - ✅ **Zero configuration**: Download and double-click to run, no need to install Python or configure environment variables - ✅ **Cross-platform**: Supports Windows 10+ and macOS 14+ - ✅ **Visual interface**: Automatically opens browser interface, no need to manually enter addresses - ⚠️ **Beta stage**: Features are continuously being improved, feedback welcome #### First Launch **Important**: The first launch may take 10-60 seconds (depending on your system configuration). The application needs to initialize the Python environment and load dependencies. Please wait patiently for the browser window to open automatically. #### macOS: Bypass System Security Restrictions When you download the CoPaw macOS app from Releases, macOS may show: *"Apple cannot verify that 'CoPaw' contains no malicious software"*. This happens because the app is not notarized. You can still open it as follows: - **Right-click to open (recommended)** Right-click (or Control+click) the CoPaw app → **Open** → in the dialog click **Open** again. This tells Gatekeeper you trust the app; after that you can double-click to launch as usual. - **Allow in System Settings** If it is still blocked, go to **System Settings → Privacy & Security**, scroll to the message like *"CoPaw was blocked because it is from an unidentified developer"*, and click **Open Anyway** or **Allow**. - **Remove quarantine attribute (not recommended for most users)** In Terminal run: `xattr -cr /Applications/CoPaw.app` (or use the path to the `.app` after unzipping). This clears the "downloaded from the internet" quarantine flag so the warning usually does not appear, but is less safe and controllable than using **Right-click → Open**. For detailed usage instructions, troubleshooting, and common issues, see the [Desktop Application Guide](https://copaw.agentscope.io/docs/desktop). ### Using Docker Images are on **Docker Hub** (`agentscope/copaw`). Image tags: `latest` (stable); `pre` (PyPI pre-release). ```bash docker pull agentscope/copaw:latest docker run -p 127.0.0.1:8088:8088 \ -v copaw-data:/app/working \ -v copaw-secrets:/app/working.secret \ agentscope/copaw:latest ``` Also available on Alibaba Cloud Container Registry (ACR) for users in China: `agentscope-registry.ap-southeast-1.cr.aliyuncs.com/agentscope/copaw` (same tags). Then open **http://127.0.0.1:8088/** for the Console. Config, memory, and skills are stored in the `copaw-data` volume; model provider settings and API keys are in the `copaw-secrets` volume. To pass API keys (e.g. `DASHSCOPE_API_KEY`), add `-e VAR=value` or `--env-file .env` to `docker run`. > **Connecting to Ollama or other services on the host machine** > > Inside a Docker container, `localhost` refers to the container itself, not your host machine. If you run Ollama (or other model services) on the host and want CoPaw in Docker to reach them, use one of these approaches: > > **Option A** — Explicit host binding (all platforms): > ```bash > docker run -p 127.0.0.1:8088:8088 \ > --add-host=host.docker.internal:host-gateway \ > -v copaw-data:/app/working \ > -v copaw-secrets:/app/working.secret \ > agentscope/copaw:latest > ``` > Then in CoPaw **Settings → Models**, change the Base URL to `http://host.docker.internal:` — for example, `http://host.docker.internal:11434` for Ollama, or `http://host.docker.internal:1234/v1` for LM Studio. > > **Option B** — Host networking (Linux only): > ```bash > docker run --network=host \ > -v copaw-data:/app/working \ > -v copaw-secrets:/app/working.secret \ > agentscope/copaw:latest > ``` > No port mapping (`-p`) is needed; the container shares the host network directly. Note that all container ports are exposed on the host, which may cause conflicts if the port is already in use. > > **Note:** If you only mount `/app/working` without a separate volume for `/app/working.secret`, the entrypoint will automatically redirect secrets into `/app/working/.secret` so they persist on the same volume. The image is built from scratch. To build the image yourself, please refer to the [Build Docker image](scripts/README.md#build-docker-image) section in `scripts/README.md`, and then push to your registry. ### Using ModelScope **No local install?** [ModelScope Studio](https://modelscope.cn/studios/fork?target=AgentScope/CoPaw) one-click cloud setup. Set your Studio to **non-public** so others cannot control your CoPaw. ### Deploy on Alibaba Cloud ECS To run CoPaw on Alibaba Cloud (ECS), use the one-click deployment: open the [CoPaw on Alibaba Cloud (ECS) deployment link](https://computenest.console.aliyun.com/service/instance/create/cn-hangzhou?type=user&ServiceId=service-1ed84201799f40879884) and follow the prompts. For step-by-step instructions, see [Alibaba Cloud Developer: Deploy your AI assistant in 3 minutes](https://developer.aliyun.com/article/1713682). --- ## API Key If you use a **cloud LLM** (e.g. DashScope, ModelScope), you must configure an API key before chatting. CoPaw will not work until a valid key is set. See the [official docs](https://copaw.agentscope.io/docs/models#configure-cloud-providers) for details. **How to configure:** 1. **Console (recommended)** — After running `copaw app`, open **http://127.0.0.1:8088/** → **Settings** → **Models**. Choose a provider, enter the **API Key**, and enable that provider and model. 2. **`copaw init`** — When you run `copaw init`, it will guide you through configuring the LLM provider and API key. Follow the prompts to choose a provider and enter your key. 3. **Environment variable** — For DashScope you can set `DASHSCOPE_API_KEY` in your shell or in a `.env` file in the working directory. Tools that need extra keys (e.g. `TAVILY_API_KEY` for web search) can be set in Console **Settings → Environment variables**, or see [Config](https://copaw.agentscope.io/docs/config) for details. > **Using local models only?** If you use [Local Models](#local-models) (llama.cpp or MLX), you do **not** need any API key. ## Local Models CoPaw can run LLMs entirely on your machine — no API keys or cloud services required. See the [official docs](https://copaw.agentscope.io/docs/models#local-providers-llamacpp--mlx) for details. | Backend | Best for | Install | | ------------- | ---------------------------------------- | -------------------------------------------------------------------- | | **llama.cpp** | Cross-platform (macOS / Linux / Windows) | `pip install 'copaw[llamacpp]'` or `bash install.sh --extras llamacpp` | | **MLX** | Apple Silicon Macs (M1/M2/M3/M4) | `pip install 'copaw[mlx]'` or `bash install.sh --extras mlx` | | **Ollama** | Cross-platform (requires Ollama service) | `pip install 'copaw[ollama]'` or `bash install.sh --extras ollama` | After installing, you can download and manage local models in the **Console** UI. You can also use the command line: ```bash copaw models download Qwen/Qwen3-4B-GGUF copaw models # select the downloaded model copaw app # start the server ``` --- ## Documentation | Topic | Description | | --------------------------------------------------------------------- | ------------------------------------------------ | | [Introduction](https://copaw.agentscope.io/docs/intro) | What CoPaw is and how to use it | | [Quick start](https://copaw.agentscope.io/docs/quickstart) | Install and run (local or ModelScope Studio) | | [Console](https://copaw.agentscope.io/docs/console) | Web UI: chat and agent configuration | | [Models](https://copaw.agentscope.io/docs/models) | Configure cloud, local, and custom providers | | [Channels](https://copaw.agentscope.io/docs/channels) | DingTalk, Feishu, QQ, Discord, iMessage, and more | | [Skills](https://copaw.agentscope.io/docs/skills) | Extend and customize capabilities | | [MCP](https://copaw.agentscope.io/docs/mcp) | Manage MCP clients | | [Memory](https://copaw.agentscope.io/docs/memory) | Long-term memory | | [Context](https://copaw.agentscope.io/docs/context) | Context management mechanism | | [Magic commands](https://copaw.agentscope.io/docs/commands) | Control conversation state without waiting for the AI | | [Heartbeat](https://copaw.agentscope.io/docs/heartbeat) | Scheduled check-in and digest | | [Config & working dir](https://copaw.agentscope.io/docs/config) | Working directory and config file | | [CLI](https://copaw.agentscope.io/docs/cli) | Init, cron jobs, skills, clean | | [FAQ](https://copaw.agentscope.io/docs/faq) | Common questions and troubleshooting | Full docs in this repo: [website/public/docs/](website/public/docs/). --- ## FAQ For common questions, troubleshooting tips, and known issues, please visit the **[FAQ page](https://copaw.agentscope.io/docs/faq)**. --- ## Roadmap | Area | Item | Status | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------- | | **Horizontal Expansion** | More channels, models, skills, MCPs — **community contributions welcome** | Seeking Contributors | | **Existing Feature Extension** | Display optimization, download hints, Windows path compatibility, etc. — **community contributions welcome** | Seeking Contributors | | **Console Web UI** | Expose more info/config in the Console | In Progress | | **Self-healing** | Magic commands and daemon capabilities (CLI, status, restart, logs) | In Progress | | | DaemonAgent: autonomous diagnostics, self-healing, and recovery | Planned | | **Multi-agent** | Background task support | In Progress | | | Multi-agent isolation | Planned | | | Inter-agent contention resolution | Planned | | | Multi-agent communication | Planned | | **Multimodal** | Voice/video calls and real-time interaction | In Progress | | **Small + Large Model Collaboration** | Train/fine-tune local small LLMs for CoPaw workflows and sensitive-data use cases | In Progress | | | Multi-model routing. Local LLMs for sensitive data; cloud LLMs for planning and coding; balance of privacy, performance, and capability | Planned | | **Memory System** | Experience distillation & skill extraction | In Progress | | | Multimodal memory fusion | Planned | | | Context-aware proactive delivery | Planned | | **Security** | Shell execution confirmation | Planned | | | Tool/skills security | Planned | | | Configurable security levels (user-configurable) | Planned | | **Release & Contributing** | Contributing guidance for vibe coding agents | Planned | | **Sandbox** | Deeper integration with AgentScope Runtime sandboxes | Long-term Planned | | **Cloud-native** | Deeper integration with AgentScope Runtime; leverage cloud compute, storage, and tooling | Long-term Planned | | **Skills Hub** | Enrich the [AgentScope Skills](https://github.com/agentscope-ai/agentscope-skills) repository and improve discoverability of high-quality skills | Long-term Planned | *Status:* *In Progress* — actively being worked on; *Planned* — queued or under design, also **welcome contributions**; *Seeking Contributors* — we **strongly encourage community contributions**; *Long-term Planned* — longer-horizon roadmap. ### Get involved We are building CoPaw in the open and welcome contributions of all kinds! Check the [Roadmap](#roadmap) above (especially items marked **Seeking Contributors**) to find areas that interest you, and read [CONTRIBUTING](https://github.com/agentscope-ai/CoPaw/blob/main/CONTRIBUTING.md) to get started. We particularly welcome: - **Horizontal expansion** — new channels, model providers, skills, MCPs. - **Existing feature extension** — display and UX improvements, download hints, Windows path compatibility, and the like. Join the conversation on [GitHub Discussions](https://github.com/agentscope-ai/CoPaw/discussions) to suggest or pick up work. --- ## Install from source ```bash git clone https://github.com/agentscope-ai/CoPaw.git cd CoPaw # Build console frontend first (required for web UI) cd console && npm ci && npm run build cd .. # Copy console build output to package directory mkdir -p src/copaw/console cp -R console/dist/. src/copaw/console/ # Install Python package pip install -e . ``` - **Dev** (tests, formatting): `pip install -e ".[dev,full]"` - **Then**: Run `copaw init --defaults`, then `copaw app`. --- ## Why CoPaw? CoPaw represents both a **Co Personal Agent Workstation** and a "co-paw"—a partner always by your side. More than just a cold tool, CoPaw is a warm "little paw" always ready to lend a hand (or a paw!). It is the ultimate teammate for your digital life. --- ## Built by [AgentScope team](https://github.com/agentscope-ai) · [AgentScope](https://github.com/agentscope-ai/agentscope) · [AgentScope Runtime](https://github.com/agentscope-ai/agentscope-runtime) · [ReMe](https://github.com/agentscope-ai/ReMe) --- ## Contact us | [Discord](https://discord.gg/eYMpfnkG8h) | [X (Twitter)](https://x.com/agentscope_ai) | [DingTalk](https://qr.dingtalk.com/action/joingroup?code=v1,k1,OmDlBXpjW+I2vWjKDsjvI9dhcXjGZi3bQiojOq3dlDw=&_dt_no_comment=1&origin=11) | | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | | [Discord](https://discord.gg/eYMpfnkG8h) | [X](https://x.com/agentscope_ai) | [DingTalk](https://qr.dingtalk.com/action/joingroup?code=v1,k1,OmDlBXpjW+I2vWjKDsjvI9dhcXjGZi3bQiojOq3dlDw=&_dt_no_comment=1&origin=11) | --- ## Telemetry CoPaw collects **anonymous** usage data during `copaw init` to help us understand our user base and prioritize improvements. Data is sent **once per version** — when you upgrade CoPaw, telemetry is re-collected so we can track version adoption. **What we collect:** - CoPaw version (e.g., 0.0.7) - Install method (pip, Docker, or desktop app) - OS and version (e.g., macOS 14.0, Ubuntu 22.04) - Python version (e.g., 3.13) - CPU architecture (e.g., x86_64, arm64) - GPU availability (yes/no) **What we do NOT collect:** No personal data, no files, no credentials, no IP addresses, no identifiable information. When running `copaw init` interactively, you will be asked whether to opt in. If you choose `--defaults`, telemetry is accepted automatically. The prompt appears once per version and never affects CoPaw's functionality. --- ## License CoPaw is released under the [Apache License 2.0](LICENSE). --- ## Contributors All thanks to our contributors: Contributors ================================================ FILE: README_ja.md ================================================
# CoPaw [![GitHub Repo](https://img.shields.io/badge/GitHub-Repo-black.svg?logo=github)](https://github.com/agentscope-ai/CoPaw) [![PyPI](https://img.shields.io/pypi/v/copaw?color=3775A9&label=PyPI&logo=pypi)](https://pypi.org/project/copaw/) [![Documentation](https://img.shields.io/badge/Docs-Website-green.svg?logo=readthedocs&label=Docs)](https://copaw.agentscope.io/) [![Python Version](https://img.shields.io/badge/python-3.10%20~%20%3C3.14-blue.svg?logo=python&label=Python)](https://www.python.org/downloads/) [![Last Commit](https://img.shields.io/github/last-commit/agentscope-ai/CoPaw)](https://github.com/agentscope-ai/CoPaw) [![License](https://img.shields.io/badge/license-Apache%202.0-red.svg?logo=apache&label=License)](LICENSE) [![Code Style](https://img.shields.io/badge/code%20style-black-black.svg?logo=python&label=CodeStyle)](https://github.com/psf/black) [![GitHub Stars](https://img.shields.io/github/stars/agentscope-ai/CoPaw?style=flat&logo=github&color=yellow&label=Stars)](https://github.com/agentscope-ai/CoPaw/stargazers) [![GitHub Forks](https://img.shields.io/github/forks/agentscope-ai/CoPaw?style=flat&logo=github&color=purple&label=Forks)](https://github.com/agentscope-ai/CoPaw/network) [![DeepWiki](https://img.shields.io/badge/DeepWiki-Ask_Devin-navy.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/agentscope-ai/CoPaw) [![Discord](https://img.shields.io/badge/Discord-Join_Us-blueviolet.svg?logo=discord)](https://discord.gg/eYMpfnkG8h) [![X](https://img.shields.io/badge/X-Follow_Us-black.svg?logo=x)](https://x.com/agentscope_ai) [![DingTalk](https://img.shields.io/badge/DingTalk-Join_Us-orange.svg)](https://qr.dingtalk.com/action/joingroup?code=v1,k1,OmDlBXpjW+I2vWjKDsjvI9dhcXjGZi3bQiojOq3dlDw=&_dt_no_comment=1&origin=11) [[ドキュメント](https://copaw.agentscope.io/)] [[English README](README.md)] [[中文 README](README_zh.md)]

CoPaw Logo

あなたのために働き、あなたとともに成長する。

あなた専用のAIアシスタント。簡単にインストールでき、ローカルマシンやクラウドにデプロイ可能。複数のチャットアプリに対応し、機能を簡単に拡張できます。 > **主な機能:** > > **あらゆるチャネル** — DingTalk、Feishu、QQ、Discord、iMessageなど。1つのアシスタントで、必要に応じて接続。 > > **あなたの管理下** — メモリとパーソナライズはあなたの管理下に。ローカルまたはクラウドにデプロイ。任意のチャネルへのスケジュールリマインダー。 > > **スキル** — 組み込みのcron機能。ワークスペース内のカスタムスキルを自動読み込み。ロックインなし。 > >
> こんなことができます > >
> > - **ソーシャル**: 人気投稿のデイリーダイジェスト(小紅書、知乎、Reddit)、Bilibili/YouTubeの要約。 > - **生産性**: ニュースレターのダイジェストをDingTalk/Feishu/QQに配信、メール/カレンダーからの連絡先管理。 > - **クリエイティブ**: 目標を伝えて一晩実行、翌日にはドラフトが完成。 > - **リサーチ**: テック/AIニュースの追跡、パーソナルナレッジベース。 > - **デスクトップ**: ファイル整理、ドキュメントの読み取り/要約、チャットでファイルをリクエスト。 > - **探索**: スキルとcronを組み合わせて、独自のエージェントアプリを構築。 > >
--- ## ニュース [2026-03-12] v0.0.7をリリースしました!詳細は[v0.0.7リリースノート](https://agentscope-ai.github.io/CoPaw/release-notes)でご確認ください。 - **[v0.0.7] 追加:** Tool Guardセキュリティレイヤー(危険なツール呼び出しをユーザー承認まで遮断); MattermostとMatrixチャネル統合; Discord/DingTalk/Feishu/Telegramの@メンションフィルタリング; Telegram Markdownレンダリング; Feishu絵文字リアクションとリッチテキストメディア; QQ画像送信; LLM呼び出し自動リトライ; LM Studioプロバイダー; トークン使用量ダッシュボード; プロバイダー`generate_kwargs`エディタ; ワークスペースファイルのドラッグ&ドロップ; チャット中のモデル切替; エージェント言語セレクター; コンテキスト管理UI; ページ遷移時のチャット状態保持; AIスキル最適化とストリーミング出力; スキルカード説明表示; 中国ユーザー向け自動PyPIミラー選択。 - **[v0.0.7] 改善:** プロバイダー接続テストメッセージ; ワークスペースzip・セッション読取の非同期化; プロバイダーID競合の自動解決; モデル検出のオンデマンド化; トークン記録の集約; 組み込みスキルドキュメントとシェルPATH処理; Himalayaメールスキル; メモリドキュメント再構成; 設定・セキュリティページのリファクタリング。 - **[v0.0.7] 修正:** DingTalk認証失敗時のクリーンアップ; Discord 2000文字超メッセージ分割; Matrix/Mattermost/MQTTチャネル設定の型整合; Windowsシェルエンコーディングとプロセスツリークリーンアップ; デスクトップSSL証明書・IME入力・外部URLナビゲーション; インストールスクリプトの関数定義順序修正; マジックコマンドセッション状態保護; Ollamaモーダル再レンダリング; `get_token_usage`データアクセス; チャットリクエストの重複排除。 - **[v0.0.7] 貢献者:** 新規貢献者の皆さんに感謝します: [@2catycm](https://github.com/2catycm), [@2niuhe](https://github.com/2niuhe), [@yingdachen](https://github.com/yingdachen), [@Atletico1999](https://github.com/Atletico1999), [@buecker](https://github.com/buecker), [@Cirilla-zmh](https://github.com/Cirilla-zmh), [@gnipping](https://github.com/gnipping), [@Nufe-muzi](https://github.com/Nufe-muzi), [@FuKunZ](https://github.com/FuKunZ), [@JasonBuildAI](https://github.com/JasonBuildAI), [@StarMoonCity](https://github.com/StarMoonCity), [@walker83](https://github.com/walker83), [@lllcy](https://github.com/lllcy)。 [2026-03-09] v0.0.6をリリースしました!詳細は[v0.0.6リリースノート](https://agentscope-ai.github.io/CoPaw/release-notes)でご確認ください。 [2026-03-06] v0.0.5をリリースしました!詳細は[v0.0.5リリースノート](https://agentscope-ai.github.io/CoPaw/release-notes)でご確認ください。 [2026-03-02] v0.0.4をリリースしました!詳細は[v0.0.4リリースノート](https://agentscope-ai.github.io/CoPaw/release-notes)でご確認ください。 --- ## 目次 > **おすすめの読み方:** > > - **3つのコマンドで実行したい**: [クイックスタート](#クイックスタート) → ブラウザでコンソールを開く。 > - **DingTalk / Feishu / QQ でチャットしたい**: コンソールで[チャネル](https://copaw.agentscope.io/docs/channels)を設定。 > - **Pythonをインストールしたくない**: [ワンラインインストール](#ワンラインインストールベータ版継続的に改善中)がPythonを自動処理。または[ModelScopeワンクリック](https://modelscope.cn/studios/fork?target=AgentScope/CoPaw)でクラウドデプロイ。 - [ニュース](#ニュース) - [クイックスタート](#クイックスタート) - [APIキー](#apiキー) - [ローカルモデル](#ローカルモデル) - [ドキュメント](#ドキュメント) - [FAQ](#faq) - [ロードマップ](#ロードマップ) - [参加方法](#参加方法) - [ソースからインストール](#ソースからインストール) - [なぜCoPaw?](#なぜcopaw) - [開発チーム](#開発チーム) - [ライセンス](#ライセンス) --- ## クイックスタート ### pip install(推奨) Pythonを自分で管理する場合: ```bash pip install copaw copaw init --defaults copaw app ``` ブラウザで **http://127.0.0.1:8088/** を開くとコンソール(CoPawとのチャット、エージェントの設定)が利用できます。DingTalk、Feishu、QQなどで会話するには、[チャネルドキュメント](https://copaw.agentscope.io/docs/channels)でチャネルを接続してください。 ![Console](https://img.alicdn.com/imgextra/i3/O1CN01VYsFVo23aAvIM3GXB_!!6000000007271-2-tps-3328-1860.png) ### ワンラインインストール(ベータ版、継続的に改善中) Pythonは不要です — インストーラーがすべて自動で処理します: **macOS / Linux:** ```bash curl -fsSL https://copaw.agentscope.io/install.sh | bash ``` Ollamaサポート付きでインストールする場合: ```bash curl -fsSL https://copaw.agentscope.io/install.sh | bash -s -- --extras ollama ``` 複数のエクストラ(例: Ollama + llama.cpp)付きでインストールする場合: ```bash curl -fsSL https://copaw.agentscope.io/install.sh | bash -s -- --extras ollama,llamacpp ``` **Windows (CMD):** ```CMD curl -fsSL https://copaw.agentscope.io/install.bat -o install.bat && install.bat ``` **Windows (PowerShell):** ```powershell irm https://copaw.agentscope.io/install.ps1 | iex ``` > **注意**: インストーラーは uv の状態を自動的に確認します。未インストールの場合はダウンロードと設定を試みます。自動インストールに失敗した場合は、画面の指示に従うか、`python -m pip install -U uv` を実行してからインストーラーを再実行してください。 > **⚠️ Windows Enterprise LTSC ユーザーへの重要なお知らせ** > > Windows LTSC または厳格なセキュリティポリシーが適用された企業環境をご利用の場合、PowerShell は **制限付き言語モード** で実行される可能性があり、以下の問題が発生する可能性があります: > 1. **CMD(.bat)使用時:スクリプトは正常に実行されるが`Path`への書き込みができない** > > スクリプトはファイルインストールを完了しましたが、**制限付き言語モード**のため環境変数への自動書き込みができません。この場合、手動で設定してください: > - **インストールディレクトリを確認**: > - `uv` の利用可否を確認:CMD で `uv --version` と入力し、バージョン番号が表示された場合は**CoPaw パスのみ設定**。`『uv』 は内部コマンドでも外部コマンドでもなく、実行可能プログラムまたはバッチファイルでもありません。` と表示された場合は両方を設定する必要があります。 > - uvパス(いずれか一つ、インストール場所に応じて選択。`uv`が利用不可の場合に記入):通常`%USERPROFILE%\.local\bin`、`%USERPROFILE%\AppData\Local\uv`、またはPythonインストールディレクトリの`Scripts`フォルダ > - CoPawパス:通常 `%USERPROFILE%\.copaw\bin` にあります。 > - **システム環境変数Pathへの手動追加**: > - `Win + R` を押し、`sysdm.cpl` と入力して Enter キーを押し、「システムのプロパティ」を開く。 > - 「詳細設定」→「環境変数」をクリック。 > - 「システム変数」で `Path` を探して選択し、「編集」をクリック。 > - 「新規」をクリックし、上記の2つのディレクトリパスを順に入力して「OK」をクリックし保存します。 > 2. **PowerShell(.ps1)を使用している場合:スクリプト実行が中断する** > > **制限付き言語モード** のため、スクリプトが自動的に`uv`をダウンロードできない可能性があります。 > - **uvを手動でインストール**: [GitHub Release](https://github.com/astral-sh/uv/releases) を参照し、`uv.exe` を `%USERPROFILE%\.local\bin` または `%USERPROFILE%\AppData\Local\uv` に配置。または Python がインストールされていることを確認し、`python -m pip install -U uv` を実行。 > - **`uv`環境変数の設定**:`uv`の配置ディレクトリと `%USERPROFILE%\.copaw\bin` をシステムの `Path` 変数に追加してください。 > - **再実行**:新しいターミナルを開き、インストールスクリプトを再度実行して `CoPaw` のインストールを完了させてください。 > - **`CoPaw`環境変数の設定**:`%USERPROFILE%\.copaw\bin` をシステムの `Path` 変数に追加します。 インストール完了後、新しいターミナルを開き、以下を実行してください: ```bash copaw init --defaults # または: copaw init(対話式) copaw app ```
インストールオプション **macOS / Linux:** ```bash # 特定のバージョンをインストール curl -fsSL ... | bash -s -- --version 0.0.2 # ソースからインストール(開発/テスト用) curl -fsSL ... | bash -s -- --from-source # ローカルモデルサポート付き bash install.sh --extras llamacpp # llama.cpp(クロスプラットフォーム) bash install.sh --extras mlx # MLX(Apple Silicon) bash install.sh --extras llamacpp,mlx # アップグレード — インストーラーを再実行するだけ curl -fsSL ... | bash # アンインストール copaw uninstall # 設定とデータを保持 copaw uninstall --purge # すべて削除 ``` **Windows (PowerShell):** ```powershell # 特定のバージョンをインストール irm ... | iex; .\install.ps1 -Version 0.0.2 # ソースからインストール(開発/テスト用) .\install.ps1 -FromSource # ローカルモデルサポート付き .\install.ps1 -Extras llamacpp # llama.cpp(クロスプラットフォーム) .\install.ps1 -Extras mlx # MLX .\install.ps1 -Extras llamacpp,mlx # アップグレード — インストーラーを再実行するだけ irm ... | iex # アンインストール copaw uninstall # 設定とデータを保持 copaw uninstall --purge # すべて削除 ```
### デスクトップアプリケーション(Beta) > **Beta版の注意事項**: デスクトップアプリケーションは現在Beta版テスト段階にあり、以下の既知の制限があります: > - **互換性テストが不完全**: すべてのシステムバージョンとハードウェア構成で十分にテストされていません > - **パフォーマンスの問題の可能性**: 起動時間、メモリ使用量などのパフォーマンス面でさらなる最適化が必要な場合があります > - **開発中の機能**: 一部の機能が不安定または欠落している可能性があります コマンドラインツールに慣れていない場合、CoPawのデスクトップアプリケーションをダウンロードして使用できます。Python環境の手動設定やコマンドの実行は不要です。 #### ダウンロード [GitHub Releases](https://github.com/agentscope-ai/CoPaw/releases)からデスクトップアプリをダウンロード: - **Windows**: `CoPaw-Setup-.exe` - **macOS**: `CoPaw--macOS.zip` (Apple Silicon推奨) #### 特徴 - ✅ **ゼロ設定**: ダウンロードしてダブルクリックするだけで実行可能、Pythonのインストールや環境変数の設定は不要 - ✅ **クロスプラットフォーム**: Windows 10+ と macOS 14+ に対応 - ✅ **ビジュアルインターフェース**: ブラウザインターフェースが自動的に開き、手動でアドレスを入力する必要はありません - ⚠️ **Beta段階**: 機能は継続的に改善中、フィードバックを歓迎します #### 初回起動 **重要**: 初回起動には10〜60秒かかる場合があります(システム構成によります)。アプリケーションはPython環境の初期化と依存関係の読み込みが必要です。ブラウザウィンドウが自動的に開くまでお待ちください。 #### macOS: システムセキュリティ制限の回避 ReleasesからCoPaw macOSアプリをダウンロードすると、macOSは次のように表示する場合があります: *「Appleは'CoPaw'に悪意のあるソフトウェアが含まれていないことを確認できません」*。これはアプリが公証されていないためです。以下の方法で開くことができます: - **右クリックして開く(推奨)** CoPawアプリを右クリック(またはControl+クリック)→ **「開く」** → ダイアログで再度 **「開く」** をクリック。これによりGatekeeperにアプリを信頼していることを伝えます。その後は通常通りダブルクリックで起動できます。 - **システム設定で許可** それでもブロックされる場合、**システム設定 → プライバシーとセキュリティ** に移動し、*「'CoPaw'は未確認の開発元からのものであるためブロックされました」* のようなメッセージまでスクロールし、**「このまま開く」** または **「許可」** をクリックします。 - **検疫属性の削除(ほとんどのユーザーには非推奨)** ターミナルで実行: `xattr -cr /Applications/CoPaw.app` (または解凍後の `.app` へのパスを使用)。これにより「インターネットからダウンロードされた」検疫フラグがクリアされ、通常は警告が表示されなくなりますが、**右クリック → 開く** を使用するよりも安全性と制御性が低くなります。 詳細な使用方法、トラブルシューティング、よくある問題については、[デスクトップアプリケーションガイド](https://copaw.agentscope.io/docs/desktop)を参照してください。 ### Dockerを使用 イメージは **Docker Hub**(`agentscope/copaw`)で公開しています。タグ: `latest`(安定版); `pre`(PyPIプレリリース版)。 ```bash docker pull agentscope/copaw:latest docker run -p 127.0.0.1:8088:8088 \ -v copaw-data:/app/working \ -v copaw-secrets:/app/working.secret \ agentscope/copaw:latest ``` 中国のユーザーは阿里雲コンテナレジストリ(ACR)も利用できます: `agentscope-registry.ap-southeast-1.cr.aliyuncs.com/agentscope/copaw`(タグは同じ)。 ブラウザで **http://127.0.0.1:8088/** を開くとコンソールが利用できます。設定、メモリ、スキルは `copaw-data` ボリュームに保存されます。モデル設定とAPIキーは `copaw-secrets` ボリュームに保存されます。APIキー(例: `DASHSCOPE_API_KEY`)を渡すには、`docker run` に `-e VAR=value` または `--env-file .env` を追加してください。 > **ホストマシン上のOllamaや他のモデルサービスに接続する** > > Dockerコンテナ内の `localhost` はコンテナ自身を指し、ホストマシンではありません。Ollama(または他のモデルサービス)がホスト上で動作している場合、以下のいずれかの方法でCoPawコンテナからアクセスできます: > > **方法A** — ホストアドレスの明示的バインディング(全プラットフォーム対応): > ```bash > docker run -p 127.0.0.1:8088:8088 \ > --add-host=host.docker.internal:host-gateway \ > -v copaw-data:/app/working \ > -v copaw-secrets:/app/working.secret \ > agentscope/copaw:latest > ``` > その後、CoPawの **Settings → Models → Ollama** で、Base URLを `http://host.docker.internal:11434` または対応するポートに変更してください。 > > **方法B** — ホストネットワーク(Linuxのみ): > ```bash > docker run --network=host \ > -v copaw-data:/app/working \ > -v copaw-secrets:/app/working.secret \ > agentscope/copaw:latest > ``` > ポートマッピング(`-p`)は不要で、コンテナはホストネットワークを直接共有します。ただし、コンテナの全ポートがホスト上に公開されるため、使用中のポートと競合する可能性があります。 > > **ヒント:** `/app/working` のみをマウントし `/app/working.secret` を別途マウントしない場合、エントリポイントスクリプトが自動的にsecretsを `/app/working/.secret` にリダイレクトし、同じボリュームに永続化します。 イメージはゼロからビルドされています。自分でイメージをビルドする場合は、`scripts/README.md` の [Build Docker image](scripts/README.md#build-docker-image) セクションを参照し、レジストリにプッシュしてください。 ### ModelScopeを使用 **ローカルインストール不要?** [ModelScope Studio](https://modelscope.cn/studios/fork?target=AgentScope/CoPaw) でワンクリッククラウドセットアップ。他の人があなたのCoPawを操作できないよう、Studioを**非公開**に設定してください。 ### Alibaba Cloud ECSへのデプロイ CoPawをAlibaba Cloud(ECS)で実行するには、ワンクリックデプロイを使用します: [CoPaw on Alibaba Cloud (ECS) デプロイリンク](https://computenest.console.aliyun.com/service/instance/create/cn-hangzhou?type=user&ServiceId=service-1ed84201799f40879884)を開き、プロンプトに従ってください。ステップバイステップの手順については、[Alibaba Cloud Developer: 3分でAIアシスタントをデプロイ](https://developer.aliyun.com/article/1713682)を参照してください。 --- ## APIキー **クラウドLLM**(例: DashScope、ModelScope)を使用する場合、チャットの前にAPIキーを設定する必要があります。有効なキーが設定されるまでCoPawは動作しません。詳細は[公式ドキュメント](https://copaw.agentscope.io/docs/models#configure-cloud-providers)をご覧ください。 **設定方法:** 1. **コンソール(推奨)** — `copaw app` 実行後、**http://127.0.0.1:8088/** を開き → **設定** → **モデル**。プロバイダーを選択し、**APIキー**を入力して、そのプロバイダーとモデルを有効にしてください。 2. **`copaw init`** — `copaw init` を実行すると、LLMプロバイダーとAPIキーの設定が案内されます。プロンプトに従ってプロバイダーを選択し、キーを入力してください。 3. **環境変数** — DashScopeの場合、シェルまたはワーキングディレクトリの `.env` ファイルで `DASHSCOPE_API_KEY` を設定できます。 その他のキー(例: Web検索用 `TAVILY_API_KEY`)は、コンソールの **設定 → 環境変数** で設定するか、[Config](https://copaw.agentscope.io/docs/config) で詳細を確認してください。 > **ローカルモデルのみ使用する場合:** [ローカルモデル](#ローカルモデル)(llama.cppまたはMLX)を使用する場合、APIキーは**不要**です。 ## ローカルモデル CoPawはLLMを完全にローカルマシン上で実行できます — APIキーやクラウドサービスは不要です。詳細は[公式ドキュメント](https://copaw.agentscope.io/docs/models#local-providers-llamacpp--mlx)をご覧ください。 | バックエンド | 最適な用途 | インストール | | ------------- | ---------------------------------------- | -------------------------------------------------------------------- | | **llama.cpp** | クロスプラットフォーム(macOS / Linux / Windows) | `pip install 'copaw[llamacpp]'` または `bash install.sh --extras llamacpp` | | **MLX** | Apple Silicon(M1/M2/M3/M4) | `pip install 'copaw[mlx]'` または `bash install.sh --extras mlx` | | **Ollama** | クロスプラットフォーム(Ollamaサービスが必要) | `pip install 'copaw[ollama]'` または `bash install.sh --extras ollama` | インストール後、**コンソール**UIでローカルモデルのダウンロードと管理ができます。コマンドラインでも利用できます: ```bash copaw models download Qwen/Qwen3-4B-GGUF copaw models # ダウンロードしたモデルを選択 copaw app # サーバーを起動 ``` --- ## ドキュメント | トピック | 説明 | | ------------------------------------------------------------------------- | --------------------------------------------------- | | [はじめに](https://copaw.agentscope.io/docs/intro) | CoPawとは何か、使い方 | | [クイックスタート](https://copaw.agentscope.io/docs/quickstart) | インストールと実行(ローカルまたはModelScope Studio) | | [コンソール](https://copaw.agentscope.io/docs/console) | Web UI: チャットとエージェント設定 | | [モデル](https://copaw.agentscope.io/docs/models) | クラウド・ローカル・カスタムプロバイダーの設定 | | [チャネル](https://copaw.agentscope.io/docs/channels) | DingTalk、Feishu、QQ、Discord、iMessageなど | | [スキル](https://copaw.agentscope.io/docs/skills) | 機能の拡張とカスタマイズ | | [MCP](https://copaw.agentscope.io/docs/mcp) | MCPクライアントの管理 | | [メモリ](https://copaw.agentscope.io/docs/memory) | 長期記憶 | | [コンテキスト](https://copaw.agentscope.io/docs/context) | コンテキスト管理メカニズム | | [魔法コマンド](https://copaw.agentscope.io/docs/commands) | AIの応答を待たずに会話状態を制御 | | [ハートビート](https://copaw.agentscope.io/docs/heartbeat) | スケジュールされたチェックインとダイジェスト | | [設定とワーキングディレクトリ](https://copaw.agentscope.io/docs/config) | ワーキングディレクトリと設定ファイル | | [CLI](https://copaw.agentscope.io/docs/cli) | Init、cronジョブ、スキル、クリーン | | [FAQ](https://copaw.agentscope.io/docs/faq) | よくある質問とトラブルシューティング | リポジトリ内の完全なドキュメント: [website/public/docs/](website/public/docs/) --- ## FAQ よくある質問、トラブルシューティングのヒント、既知の問題については、**[FAQページ](https://copaw.agentscope.io/docs/faq)** をご覧ください。 --- ## ロードマップ | 方向 | 項目 | 状態 | | --- | --- | --- | | **横展開** | より多くのチャネル、モデル、スキル、MCP など — **コミュニティの貢献歓迎** | 貢献者募集中 | | **既存機能の拡張・改善** | 表示の最適化、ダウンロードヒント、Windowsパス互換など — **コミュニティの貢献歓迎** | 貢献者募集中 | | **コンソール Web UI** | コンソールでより多くの情報と設定を公開 | 進行中 | | **自己修復** | マジックコマンドとデーモン機能(CLI、status、restart、logs) | 進行中 | | | DaemonAgent: 自律診断、自己修復、復旧 | 計画中 | | **マルチエージェント** | バックグラウンドタスクサポート | 進行中 | | | マルチエージェントの分離 | 計画中 | | | エージェント間の競合・衝突の解決 | 計画中 | | | マルチエージェント通信 | 計画中 | | **マルチモーダル** | 音声/ビデオ通話とリアルタイム対話 | 進行中 | | **大小モデル協調** | CoPaw ワークフローと機密データ向けのローカル小モデル学習・ファインチューニング | 進行中 | | | マルチモデルルーティング。ローカルモデルで機密データ処理、クラウドモデルで計画・コーディング;プライバシー・性能・能力の両立 | 計画中 | | **メモリシステム** | 経験の蓄積とスキル抽出 | 進行中 | | | マルチモーダルメモリの融合強化 | 計画中 | | | シーン認識による能動的プッシュ | 計画中 | | **セキュリティ** | シェル実行の確認 | 計画中 | | | ツール/スキルのセキュリティ | 計画中 | | | 設定可能なセキュリティレベル | 計画中 | | **バージョンリリース・貢献規範** | Vibe Coding 等のエージェント向け貢献ガイダンス | 計画中 | | **サンドボックス** | AgentScope Runtime サンドボックスとの深い統合 | 長期計画 | | **クラウドネイティブ** | AgentScope Runtime との深い統合、クラウド算力・ストレージ・ツールエコシステムの活用 | 長期計画 | | **スキルエコシステム** | [AgentScope Skills](https://github.com/agentscope-ai/agentscope-skills) リポジトリの充実、高品質スキルの発見・利用向上 | 長期計画 | *状態説明:進行中 — 推進中;計画中 — 予定または設計中、**貢献も歓迎**;**貢献者募集中** — **コミュニティの参加を歓迎**;長期計画 — 中長期ロードマップ。* ### 参加方法 CoPawはオープンに開発しており、あらゆる形の貢献を歓迎しています!上記の[ロードマップ](#ロードマップ)(特に**貢献者募集中**の項目)から興味のある領域を選び、[CONTRIBUTING](https://github.com/agentscope-ai/CoPaw/blob/main/CONTRIBUTING.md)を読んで始めてください。特に歓迎するのは: - **横展開** — 新規チャネル、モデルプロバイダー、スキル、MCP。 - **既存機能の拡張・改善** — 表示とインタラクションの最適化、ダウンロードヒント、Windowsパス互換など。 [GitHub Discussions](https://github.com/agentscope-ai/CoPaw/discussions)で議論に参加し、アイデアを提案したりタスクを担当したりしてください。 --- ## ソースからインストール ```bash git clone https://github.com/agentscope-ai/CoPaw.git cd CoPaw # まずコンソールフロントエンドをビルド(Web UIに必須) cd console && npm ci && npm run build cd .. # コンソールのビルド出力をパッケージディレクトリにコピー mkdir -p src/copaw/console cp -R console/dist/. src/copaw/console/ # Pythonパッケージのインストール pip install -e . ``` - **開発**(テスト、フォーマット): `pip install -e ".[dev,full]"` - **その後**: `copaw init --defaults` を実行し、次に `copaw app` を実行。 --- ## なぜCoPaw? CoPawは **Co Personal Agent Workstation**(共同パーソナルエージェントワークステーション)であると同時に、「co-paw」— いつもあなたのそばにいるパートナーを表しています。単なる冷たいツールではなく、CoPawはいつでも手(または肉球!)を貸してくれる温かい「小さな肉球」です。デジタルライフにおける究極のチームメイトです。 --- ## 開発チーム [AgentScope team](https://github.com/agentscope-ai) · [AgentScope](https://github.com/agentscope-ai/agentscope) · [AgentScope Runtime](https://github.com/agentscope-ai/agentscope-runtime) · [ReMe](https://github.com/agentscope-ai/ReMe) --- ## お問い合わせ | [Discord](https://discord.gg/eYMpfnkG8h) | [X (Twitter)](https://x.com/agentscope_ai) | [DingTalk](https://qr.dingtalk.com/action/joingroup?code=v1,k1,OmDlBXpjW+I2vWjKDsjvI9dhcXjGZi3bQiojOq3dlDw=&_dt_no_comment=1&origin=11) | | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | | [Discord](https://discord.gg/eYMpfnkG8h) | [X](https://x.com/agentscope_ai) | [DingTalk](https://qr.dingtalk.com/action/joingroup?code=v1,k1,OmDlBXpjW+I2vWjKDsjvI9dhcXjGZi3bQiojOq3dlDw=&_dt_no_comment=1&origin=11) | --- ## テレメトリ CoPawは `copaw init` 実行時に**匿名**の利用データを収集し、ユーザー環境の把握と製品改善に役立てています。データは**バージョンごとに1回**送信されます — CoPawをアップグレードすると、バージョン分布を把握するために再収集されます。 **収集する情報:** - CoPawバージョン(例: 0.0.7) - インストール方法(pip、Docker、またはデスクトップアプリ) - OSとバージョン(例: macOS 14.0、Ubuntu 22.04) - Pythonバージョン(例: 3.13) - CPUアーキテクチャ(例: x86_64、arm64) - GPUの利用可否(はい/いいえ) **収集しないもの:** 個人データ、ファイル、認証情報、IPアドレス、個人を特定できる情報は一切収集しません。 `copaw init` を対話モードで実行すると、同意するかどうか尋ねられます。`--defaults` モードでは自動的に同意されます。プロンプトはバージョンごとに1回のみ表示され、CoPawの機能には影響しません。 --- ## ライセンス CoPawは[Apache License 2.0](LICENSE)の下でリリースされています。 --- ## コントリビューター CoPawをより良くするために貢献してくださったすべての方々に感謝します: コントリビューター ================================================ FILE: README_zh.md ================================================
# CoPaw [![GitHub 仓库](https://img.shields.io/badge/GitHub-仓库-black.svg?logo=github)](https://github.com/agentscope-ai/CoPaw) [![PyPI](https://img.shields.io/pypi/v/copaw?color=3775A9&label=PyPI&logo=pypi)](https://pypi.org/project/copaw/) [![文档](https://img.shields.io/badge/文档-在线-green.svg?logo=readthedocs&label=Docs)](https://copaw.agentscope.io/) [![Python 版本](https://img.shields.io/badge/python-3.10%20~%20%3C3.14-blue.svg?logo=python&label=Python)](https://www.python.org/downloads/) [![最后提交](https://img.shields.io/github/last-commit/agentscope-ai/CoPaw)](https://github.com/agentscope-ai/CoPaw) [![许可证](https://img.shields.io/badge/license-Apache%202.0-red.svg?logo=apache&label=%E8%AE%B8%E5%8F%AF%E8%AF%81)](LICENSE) [![代码风格](https://img.shields.io/badge/code%20style-black-black.svg?logo=python&label=%E4%BB%A3%E7%A0%81%E9%A3%8E%E6%A0%BC)](https://github.com/psf/black) [![GitHub Star](https://img.shields.io/github/stars/agentscope-ai/CoPaw?style=flat&logo=github&color=yellow&label=Star)](https://github.com/agentscope-ai/CoPaw/stargazers) [![GitHub Fork](https://img.shields.io/github/forks/agentscope-ai/CoPaw?style=flat&logo=github&color=purple&label=Fork)](https://github.com/agentscope-ai/CoPaw/network) [![DeepWiki](https://img.shields.io/badge/DeepWiki-Ask_Devin-navy.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/agentscope-ai/CoPaw) [![Discord](https://img.shields.io/badge/Discord-Join_Us-blueviolet.svg?logo=discord)](https://discord.gg/eYMpfnkG8h) [![X](https://img.shields.io/badge/X-Follow_Us-black.svg?logo=x)](https://x.com/agentscope_ai) [![钉钉群](https://img.shields.io/badge/DingTalk-Join_Us-orange.svg)](https://qr.dingtalk.com/action/joingroup?code=v1,k1,OmDlBXpjW+I2vWjKDsjvI9dhcXjGZi3bQiojOq3dlDw=&_dt_no_comment=1&origin=11) [[文档](https://copaw.agentscope.io/)] [[English](README.md)] [[日本語](README_ja.md)]

CoPaw Logo

懂你所需,伴你左右。

你的AI个人助理;安装极简、本地与云上均可部署;支持多端接入、能力轻松扩展。 > **核心能力:** > > **全域触达** — 钉钉、飞书、QQ、Discord、iMessage 等频道,一个 CoPaw 按需连接。 > > **由你掌控** — 记忆与个性化由你掌控,本地或云端均可;定时与协作发往指定频道。 > > **Skills 扩展** — 内置定时任务,自定义技能目录,CoPaw 自动加载,无绑定。 > >
> 你可以用 CoPaw 做什么 > >
> > - **社交媒体**:每日热帖摘要(小红书、知乎、Reddit),B 站/YouTube 新视频摘要。 > - **生产力**:邮件与 Newsletter 精华推送到钉钉/飞书/QQ,邮件与日历整理联系人。 > - **创意与构建**:睡前说明目标、自动执行,次日获得雏形;从选题到成片全流程。 > - **研究与学习**:追踪科技与 AI 资讯,个人知识库检索复用。 > - **桌面与文件**:整理与搜索本地文件、阅读与摘要文档,在会话中索要文件。 > - **探索更多**:用 Skills 与定时任务组合成你自己的 agentic app。 > >
--- ## 新闻 [2026-03-18] 我们发布了 v0.1.0!完整更新说明见 [v0.1.0 发布说明](https://agentscope-ai.github.io/CoPaw/release-notes)。 - **[v0.1.0] 新增:** 多工作区架构,支持 Agent 切换器;技能安全扫描和危险 Shell 命令检测;可选 Web 认证;企业微信和小艺通道;钉钉 AI 卡片回复;Gemini、DeepSeek、MiniMax、Kimi 模型供应商;控制台暗色模式和多模态聊天;基于 SSE 的流式对话(支持重连);Whisper 语音转文字;`view_image` 多模态对话工具;LobeHub、魔搭和 Zip 压缩包技能导入;`glob_search` 和 `grep_search` 内置搜索工具;时区选择器;`copaw update` 自升级命令。 - **[v0.1.0] 优化:** 优雅的生命周期管理(Agent 零停机重载);动态 Agent 级 Token 计数;配置加载保护;控制台多语言改进(含聊天提示词本地化);Windows 桌面端字节码预编译加速启动;QQ 频道回复逻辑优化和私聊支持。 - **[v0.1.0] 修复:** Telegram 消息线程、媒体处理和自动重连;Discord 跨频道消息合并和防抖泛化;飞书通道重载;Ollama/LM Studio 上下文长度和错误提示;定时任务工作区修复;聊天会话导航持久化;Windows 跨磁盘文件移动、AutoRun stderr 和 GBK 编码。 - **[v0.1.0] 贡献者:** 感谢新贡献者:[@dipeshbabu](https://github.com/dipeshbabu)、[@sljeff](https://github.com/sljeff)、[@octo-patch](https://github.com/octo-patch)、[@Alexxigang](https://github.com/Alexxigang)、[@howyoungchen](https://github.com/howyoungchen)、[@nphenix](https://github.com/nphenix)、[@skyfaker](https://github.com/skyfaker)、[@hh0592821](https://github.com/hh0592821)、[@futuremeng](https://github.com/futuremeng)、[@toby1123yjh](https://github.com/toby1123yjh)、[@hiyuchang](https://github.com/hiyuchang)、[@hanson-hex](https://github.com/hanson-hex)、[@JackyMao1999](https://github.com/JackyMao1999)、[@mvanhorn](https://github.com/mvanhorn)、[@yuanxs21](https://github.com/yuanxs21)、[@aissac](https://github.com/aissac)、[@lcq225](https://github.com/lcq225)、[@Justin-lu](https://github.com/Justin-lu)、[@rowanchen-com](https://github.com/rowanchen-com)、[@pzlav](https://github.com/pzlav)、[@mautops](https://github.com/mautops)、[@hikariming](https://github.com/hikariming)、[@Vanlee0129](https://github.com/Vanlee0129)、[@JiwaniZakir](https://github.com/JiwaniZakir)、[@EuanTop](https://github.com/EuanTop)。 [2026-03-12] 我们发布了 v0.0.7!完整更新说明见 [v0.0.7 发布说明](https://agentscope-ai.github.io/CoPaw/release-notes)。 [2026-03-09] 我们发布了 v0.0.6!完整更新说明见 [v0.0.6 发布说明](https://agentscope-ai.github.io/CoPaw/release-notes)。 [2026-03-06] 我们发布了 v0.0.5!完整更新说明见 [v0.0.5 发布说明](https://agentscope-ai.github.io/CoPaw/release-notes)。 [2026-03-02] 我们发布了 v0.0.4!完整更新说明见 [v0.0.4 发布说明](https://agentscope-ai.github.io/CoPaw/release-notes)。 --- ## 目录 > **推荐阅读:** > > - **我想三条命令跑起来**: [快速开始](#快速开始) → 浏览器打开控制台。 > - **我想在钉钉 / 飞书 / QQ 里聊**:在控制台中进行 [频道配置](https://copaw.agentscope.io/docs/channels)。 > - **我不想装 Python**:[脚本安装](#脚本安装) 自动管理 Python,或使用 [魔搭一键配置](https://modelscope.cn/studios/fork?target=AgentScope/CoPaw) 云端部署。 - [新闻](#新闻) - [快速开始](#快速开始) - [API Key](#api-key) - [本地模型](#本地模型) - [文档](#文档) - [常见问题](#常见问题) - [路线图](#路线图) - [参与贡献](#参与贡献) - [从源码安装](#从源码安装) - [为什么叫 CoPaw?](#为什么叫-copaw) - [由谁构建](#由谁构建) - [许可证](#许可证) --- ## 快速开始 ### pip 安装 如果你习惯自行管理 Python 环境: ```bash pip install copaw copaw init --defaults copaw app ``` 在浏览器打开 **http://127.0.0.1:8088/** 即可使用控制台(与 CoPaw 对话、配置 Agent)。若要在钉钉、飞书、QQ 等 app 内对话,请参考 [文档](https://copaw.agentscope.io/docs/channels) 接入频道。 ![Console](https://img.alicdn.com/imgextra/i3/O1CN01N6TeJ41Y2y7O4gppz_!!6000000003002-2-tps-3328-1860.png) ### 脚本安装 无需手动配置 Python,一行命令自动完成安装。脚本会自动下载 uv(Python 包管理器)、创建虚拟环境、安装 CoPaw 及其依赖(含 Node.js 和前端资源)。注意:部分网络环境或企业权限管控下可能无法使用。 **macOS / Linux:** ```bash curl -fsSL https://copaw.agentscope.io/install.sh | bash ``` 如需安装 Ollama 支持: ```bash curl -fsSL https://copaw.agentscope.io/install.sh | bash -s -- --extras ollama ``` 如需安装多个扩展(例如 Ollama + llama.cpp): ```bash curl -fsSL https://copaw.agentscope.io/install.sh | bash -s -- --extras ollama,llamacpp ``` **Windows (CMD):** ```CMD curl -fsSL https://copaw.agentscope.io/install.bat -o install.bat && install.bat ``` **Windows(PowerShell):** ```powershell irm https://copaw.agentscope.io/install.ps1 | iex ``` > **注意**:安装程序将自动检查 uv 状态,若未安装则尝试自动下载配置。如遇自动安装失败,请遵循屏幕提示操作,或执行 `python -m pip install -U uv`,然后重新运行安装程序。 > **⚠️ Windows 企业版 LTSC 用户特别提示** > > 如果您使用的是 Windows LTSC 或受严格安全策略管控的企业环境,PowerShell 可能运行在 **受限语言模式** 下,可能会遇到以下问题: > 1. **如果你使用的是 CMD(.bat):脚本执行成功但无法写入`Path`** > > 脚本已完成文件安装,由于 **受限语言模式** ,脚本无法自动写入环境变量,此时只需手动配置: > - **找到安装目录**: > - 检查 `uv` 是否可用:在 CMD 中输入 `uv --version` ,如果显示版本号,则**只需配置 CoPaw 路径**;如果提示 `'uv' 不是内部或外部命令,也不是可运行的程序或批处理文件。`,则需同时配置两者。 > - uv路径(任选其一,取决于安装位置,若`uv`不可用则填):通常在`%USERPROFILE%\.local\bin`、`%USERPROFILE%\AppData\Local\uv`或 Python 安装目录下的 `Scripts` 文件夹 > - CoPaw路径:通常在 `%USERPROFILE%\.copaw\bin` 。 > - **手动添加到系统的 Path 环境变量**: > - 按 `Win + R`,输入 `sysdm.cpl` 并回车,打开“系统属性”。 > - 点击 “高级” -> “环境变量”。 > - 在 “系统变量” 中找到并选中 `Path`,点击 “编辑”。 > - 点击 “新建”,依次填入上述两个目录路径,点击确定保存。 > 2. **如果你使用的是 PowerShell(.ps1):脚本运行中断** > > 由于 **受限语言模式** ,脚本可能无法自动下载`uv`。 > - **手动安装uv**:参考 [GitHub Release](https://github.com/astral-sh/uv/releases)下载并将`uv.exe`放至`%USERPROFILE%\.local\bin`或`%USERPROFILE%\AppData\Local\uv`;或者确保已安装 Python ,然后运行`python -m pip install -U uv` > - **配置`uv`环境变量**:将`uv`所在目录和 `%USERPROFILE%\.copaw\bin` 添加到系统的 `Path` 变量中。 > - **重新运行**:打开新终端,再次执行安装脚本以完成 `CoPaw` 安装。 > - **配置`CoPaw`环境变量**:将 `%USERPROFILE%\.copaw\bin` 添加到系统的 `Path` 变量中。 安装完成后,请打开新终端并运行: ```bash copaw init --defaults # 或:copaw init(交互式) copaw app ```
安装选项 **macOS / Linux:** ```bash # 安装指定版本 curl -fsSL ... | bash -s -- --version 0.0.2 # 从源码安装(开发/测试用) curl -fsSL ... | bash -s -- --from-source # 安装本地模型支持 bash install.sh --extras llamacpp # llama.cpp(跨平台) bash install.sh --extras mlx # MLX(Apple Silicon) bash install.sh --extras llamacpp,mlx # 升级 — 重新运行安装命令即可 curl -fsSL ... | bash # 卸载 copaw uninstall # 保留配置和数据 copaw uninstall --purge # 删除所有内容 ``` **Windows(PowerShell):** ```powershell # 安装指定版本 irm ... | iex; .\install.ps1 -Version 0.0.2 # 从源码安装(开发/测试用) .\install.ps1 -FromSource # 安装本地模型支持 .\install.ps1 -Extras llamacpp # llama.cpp(跨平台) .\install.ps1 -Extras mlx # MLX .\install.ps1 -Extras llamacpp,mlx # 升级 — 重新运行安装命令即可 irm ... | iex # 卸载 copaw uninstall # 保留配置和数据 copaw uninstall --purge # 删除所有内容 ```
### 桌面应用(Beta) > **Beta 版本说明**:桌面应用目前处于 Beta 测试阶段,存在以下已知限制: > - **兼容性测试不完整**:未在所有系统版本和硬件配置上进行充分测试 > - **性能可能存在缺陷**:启动速度、内存占用等方面可能需要进一步优化 > - **功能持续完善中**:部分功能可能不稳定或缺失 如果你不习惯使用命令行,可以下载并使用 CoPaw 的桌面应用版本,无需手动配置 Python 环境或执行命令。 #### 下载 从 [GitHub Releases](https://github.com/agentscope-ai/CoPaw/releases) 下载桌面应用: - **Windows**: `CoPaw-Setup-.exe` - **macOS**: `CoPaw--macOS.zip` (推荐 Apple Silicon) #### 特点 - ✅ **零配置**:下载后双击即可运行,无需安装 Python 或配置环境变量 - ✅ **跨平台**:支持 Windows 10+ 和 macOS 14+ - ✅ **可视化**:自动打开浏览器界面,无需手动输入地址 - ⚠️ **Beta 阶段**:功能持续完善中,欢迎反馈问题 #### 首次启动 **重要提示**:首次启动可能需要 10-60 秒(取决于您的系统配置)。应用需要初始化 Python 环境和加载依赖,请耐心等待浏览器窗口自动打开。 #### macOS:绕过系统安全限制 当你从 Releases 下载 CoPaw macOS 应用时,macOS 可能显示:*"Apple 无法验证 'CoPaw' 不包含恶意软件"*。这是因为应用未经过公证。你仍然可以通过以下方式打开: - **右键打开(推荐)** 右键点击(或 Control + 点击)CoPaw 应用 → **"打开"** → 在对话框中再次点击 **"打开"**。这会告诉 Gatekeeper 你信任该应用;之后可以像往常一样双击启动。 - **在系统设置中允许** 如果仍被阻止,进入 **系统设置 → 隐私与安全性**,向下滚动找到类似 *"已阻止 'CoPaw',因为无法验证开发者"* 的提示,点击 **"仍要打开"** 或 **"允许"**。 - **移除隔离属性(不推荐大多数用户)** 在终端运行: `xattr -cr /Applications/CoPaw.app` (或使用解压后的 `.app` 路径)。这会清除"从互联网下载"的隔离标志,使警告通常不会出现,但不如使用 **右键 → 打开** 安全和可控。 详细使用说明、故障排除和常见问题,请参见 [桌面应用指南](https://copaw.agentscope.io/docs/desktop)。 --- ### 使用 Docker 镜像在 **Docker Hub**(`agentscope/copaw`)。镜像 tag:`latest`(稳定版);`pre`(PyPI 预发布版)。 ```bash docker pull agentscope/copaw:latest docker run -p 127.0.0.1:8088:8088 \ -v copaw-data:/app/working \ -v copaw-secrets:/app/working.secret \ agentscope/copaw:latest ``` 国内用户也可选用阿里云容器镜像服务 (ACR):`agentscope-registry.ap-southeast-1.cr.aliyuncs.com/agentscope/copaw`(tag 相同)。 然后在浏览器打开 **http://127.0.0.1:8088/** 进入控制台。配置、记忆与 Skills 保存在 `copaw-data` 卷中;模型配置与 API Key 保存在 `copaw-secrets` 卷中。如需传入 API Key(如 `DASHSCOPE_API_KEY`),在 `docker run` 时添加 `-e VAR=value` 或 `--env-file .env`。 > **从容器内连接宿主机上的 Ollama 或其他模型服务** > > Docker 容器内的 `localhost` 指向容器自身,而非宿主机。如果 Ollama(或其他模型服务)运行在宿主机上,可通过以下方式让容器内的 CoPaw 访问: > > **方式 A** — 显式绑定宿主机地址(全平台通用): > ```bash > docker run -p 127.0.0.1:8088:8088 \ > --add-host=host.docker.internal:host-gateway \ > -v copaw-data:/app/working \ > -v copaw-secrets:/app/working.secret \ > agentscope/copaw:latest > ``` > 然后在 CoPaw **设置 → 模型** 中,将 Base URL 改为 `http://host.docker.internal:<端口>` — 例如 Ollama 填 `http://host.docker.internal:11434`,LM Studio 填 `http://host.docker.internal:1234/v1`。 > > **方式 B** — 使用宿主机网络(仅限 Linux): > ```bash > docker run --network=host \ > -v copaw-data:/app/working \ > -v copaw-secrets:/app/working.secret \ > agentscope/copaw:latest > ``` > 无需端口映射(`-p`),容器直接共享宿主机网络。注意这会将容器的所有端口暴露在宿主机上,可能与已占用的端口产生冲突。 > > **提示:** 如果你只挂载了 `/app/working` 而没有单独挂载 `/app/working.secret`,入口脚本会自动将 secrets 重定向到 `/app/working/.secret`,使其也保存在同一个 volume 中。 镜像从零构建。若需自行构建镜像,请参阅 [scripts/README.md](scripts/README.md#build-docker-image) 中的「Build Docker image」小节,构建后推送到你的镜像仓库。 ### 使用魔搭创空间 **不想本地安装?** 使用 [魔搭创空间](https://modelscope.cn/studios/fork?target=AgentScope/CoPaw) 一键云端配置。请将创空间设为 **非公开**,否则他人可能操纵你的 CoPaw。 ### 部署到阿里云 ECS 若希望将 CoPaw 部署在阿里云上,可使用阿里云 ECS 一键部署:打开 [CoPaw 阿里云 ECS 部署链接](https://computenest.console.aliyun.com/service/instance/create/cn-hangzhou?type=user&ServiceId=service-1ed84201799f40879884) 按页面提示操作即可。详细步骤见 [阿里云开发者社区:CoPaw 3 分钟部署你的 AI 助理](https://developer.aliyun.com/article/1713682)。 --- ## API Key 若使用**云端大模型**(如 DashScope、ModelScope),在开始对话前必须配置 API Key。未配置有效 Key 前,CoPaw 无法正常工作。详情请参考[官方文档](https://copaw.agentscope.io/docs/models#%E9%85%8D%E7%BD%AE%E4%BA%91%E6%8F%90%E4%BE%9B%E5%95%86)。 **配置方式:** 1. **控制台(推荐)** — 运行 `copaw app` 后,打开 **http://127.0.0.1:8088/** → **设置** → **模型**。选择提供商、填写 **API Key**,并启用该提供商与模型。 2. **`copaw init`** — 运行 `copaw init` 时,会引导你配置 LLM 提供商与 API Key。按提示选择提供商并填写 Key 即可。 3. **环境变量** — 使用 DashScope 时,可在终端或工作目录下的 `.env` 文件中设置 `DASHSCOPE_API_KEY`。 其他工具所需密钥(如网页搜索的 `TAVILY_API_KEY`)可在控制台 **设置 → 环境变量** 中配置,详见 [配置](https://copaw.agentscope.io/docs/config)。 > **仅用本地模型?** 若使用 [本地模型](#本地模型)(llama.cpp 或 MLX),则**无需**任何 API Key。 --- ## 本地模型 CoPaw 可在本机完全本地运行大模型,无需 API Key 或云端服务。详情请见[官方文档](https://copaw.agentscope.io/docs/models#%E6%9C%AC%E5%9C%B0%E6%8F%90%E4%BE%9B%E5%95%86llamacpp--MLX) | 后端 | 适用场景 | 安装 | | ------------- | --------------------------------- | -------------------------------------------------------------------- | | **llama.cpp** | 跨平台(macOS / Linux / Windows) | `pip install 'copaw[llamacpp]'` 或 `bash install.sh --extras llamacpp` | | **MLX** | Apple Silicon(M1/M2/M3/M4) | `pip install 'copaw[mlx]'` 或 `bash install.sh --extras mlx` | | **Ollama** | 跨平台(需要 Ollama 服务运行) | `pip install 'copaw[ollama]'` 或 `bash install.sh --extras ollama` | 安装后可以在 **控制台** 界面中下载与管理本地模型。 也可以用命令行管理模型: ```bash copaw models download Qwen/Qwen3-4B-GGUF copaw models # 选择已下载的模型 copaw app # 启动服务 ``` --- ## 文档 | 主题 | 说明 | | --------------------------------------------------------- | ------------------------------------ | | [项目介绍](https://copaw.agentscope.io/docs/intro) | CoPaw 是什么、怎么用 | | [快速开始](https://copaw.agentscope.io/docs/quickstart) | 安装与运行(本地或魔搭创空间) | | [控制台](https://copaw.agentscope.io/docs/console) | Web 界面:对话与 Agent 配置 | | [模型](https://copaw.agentscope.io/docs/models) | 配置云/本地/自定义提供商 | | [频道配置](https://copaw.agentscope.io/docs/channels) | 钉钉、飞书、QQ、Discord、iMessage 等 | | [Skills](https://copaw.agentscope.io/docs/skills) | 扩展与自定义能力 | | [MCP](https://copaw.agentscope.io/docs/mcp) | 管理 MCP 客户端 | | [记忆](https://copaw.agentscope.io/docs/memory) | 长期记忆 | | [上下文](https://copaw.agentscope.io/docs/context) | 上下文管理机制 | | [魔法命令](https://copaw.agentscope.io/docs/commands) | 控制对话状态,无需等待AI理解 | | [心跳](https://copaw.agentscope.io/docs/heartbeat) | 定时自检与摘要 | | [配置与工作目录](https://copaw.agentscope.io/docs/config) | 工作目录与配置文件 | | [CLI](https://copaw.agentscope.io/docs/cli) | 初始化、定时任务、Skills、清理 | | [FAQ 常见问题](https://copaw.agentscope.io/docs/faq) | 常见问题与报错排查 | 完整文档见本仓库 [website/public/docs/](website/public/docs/)。 --- ## 常见问题 常见问题、排错指南与已知问题,请访问 **[FAQ 页面](https://copaw.agentscope.io/docs/faq)**。 --- ## 路线图 | 方向 | 事项 | 状态 | | ---------------------- | ----------------------------------------------------------------------------------------------------------- | -------- | | **横向拓展** | 更多频道、模型、技能、MCP 等 — **欢迎社区贡献** | 征集中 | | **已有功能扩展与完善** | 展示优化、下载提示、Windows 路径兼容等 — **欢迎社区贡献** | 征集中 | | **控制台 Web UI** | 在控制台中透出更多信息与配置 | 进行中 | | **自愈** | 魔法命令与 Daemon 能力(CLI、status、restart、logs) | 进行中 | | | DaemonAgent:自诊断、自愈与恢复 | 计划中 | | **多智能体** | 后台任务支持 | 进行中 | | | 多智能体隔离 | 计划中 | | | 智能体间竞争与冲突的解决 | 计划中 | | | 多智能体通信 | 计划中 | | **多模态** | 语音/视频通话与实时交互 | 进行中 | | **大小模型协同** | 针对 CoPaw 工作流与敏感数据场景的本地小模型训练与微调 | 进行中 | | | 多模型路由。本地模型处理敏感数据,云端模型负责规划与编码;兼顾隐私、性能与能力 | 计划中 | | **记忆系统** | 经验沉淀技能提炼 | 进行中 | | | 多模态记忆融合增强 | 计划中 | | | 场景感知主动推送 | 计划中 | | **安全** | Shell 执行确认 | 计划中 | | | 工具/技能安全性 | 计划中 | | | 可配置安全等级 | 计划中 | | **版本发布与贡献规范** | Vibe Coding 等 Agent 的贡献引导 | 计划中 | | **沙箱** | 与 AgentScope Runtime 沙箱深度集成 | 长期规划 | | **云原生** | 与 AgentScope Runtime 深度集成,充分利用云端算力、存储与工具生态 | 长期规划 | | **技能生态** | 丰富 [AgentScope Skills](https://github.com/agentscope-ai/agentscope-skills) 仓库,提升优质技能的发现与使用 | 长期规划 | *状态说明:进行中 — 正在推进;计划中 — 已排期或设计中,也**欢迎贡献**;**征集中** — 我们**非常欢迎**社区参与;长期规划 — 中长期路线。* ### 参与贡献 CoPaw 在开放协作中持续演进,欢迎各种形式的参与!请参考上方 [路线图](#路线图)(尤其是标记为 **征集中** 的项)选择你感兴趣的方向,并阅读 [CONTRIBUTING](https://github.com/agentscope-ai/CoPaw/blob/main/CONTRIBUTING_zh.md) 了解如何开始。我们特别欢迎: - **横向拓展** — 新频道、模型提供商、Skills、MCP。 - **已有功能扩展与完善** — 展示与交互优化、下载提示、Windows 路径兼容等。 欢迎在 [GitHub Discussions](https://github.com/agentscope-ai/CoPaw/discussions) 参与讨论、提出想法或认领任务。 --- ## 从源码安装 ```bash git clone https://github.com/agentscope-ai/CoPaw.git cd CoPaw # 先构建前端控制台(Web 界面必需) cd console && npm ci && npm run build cd .. # 将控制台构建产物复制到包目录 mkdir -p src/copaw/console cp -R console/dist/. src/copaw/console/ # 安装 Python 包 pip install -e . ``` - **开发**(测试、格式化):`pip install -e ".[dev,full]"` - **然后**:运行 `copaw init --defaults`,再运行 `copaw app`。 --- ## 为什么叫 CoPaw? CoPaw 既是「你的搭档小爪子」(co-paw),也寓意 **Co Personal Agent Workstation**(协同个人智能体工作台)。我们希望它不是冰冷的工具,而是一只随时准备帮忙的温暖「小爪子」,是你数字生活中最默契的伙伴。 --- ## 由谁构建 [AgentScope 团队](https://github.com/agentscope-ai) · [AgentScope](https://github.com/agentscope-ai/agentscope) · [AgentScope Runtime](https://github.com/agentscope-ai/agentscope-runtime) · [ReMe](https://github.com/agentscope-ai/ReMe) --- ## 联系我们 | [Discord](https://discord.gg/eYMpfnkG8h) | [X (Twitter)](https://x.com/agentscope_ai) | [钉钉](https://qr.dingtalk.com/action/joingroup?code=v1,k1,OmDlBXpjW+I2vWjKDsjvI9dhcXjGZi3bQiojOq3dlDw=&_dt_no_comment=1&origin=11) | | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | | [Discord](https://discord.gg/eYMpfnkG8h) | [X](https://x.com/agentscope_ai) | [钉钉](https://qr.dingtalk.com/action/joingroup?code=v1,k1,OmDlBXpjW+I2vWjKDsjvI9dhcXjGZi3bQiojOq3dlDw=&_dt_no_comment=1&origin=11) | --- ## 遥测数据 CoPaw 在执行 `copaw init` 时会收集**匿名**使用数据,帮助我们了解用户环境并优化产品。数据**每个版本收集一次** — 当你升级 CoPaw 后,会重新收集以便我们了解版本分布。 **收集的信息:** - CoPaw 版本(如 0.0.7) - 安装方式(pip、Docker 或桌面应用) - 操作系统及版本(如 macOS 14.0、Ubuntu 22.04) - Python 版本(如 3.13) - CPU 架构(如 x86_64、arm64) - GPU 是否可用(是/否) **不收集:** 不涉及任何个人数据、文件、密钥、IP 地址或可识别信息。 交互式运行 `copaw init` 时,会询问你是否同意。使用 `--defaults` 模式则自动同意。提示每个版本仅出现一次,且不影响 CoPaw 的任何功能。 --- ## 许可证 CoPaw 采用 [Apache License 2.0](LICENSE) 开源协议。 --- ## 贡献者 感谢所有为 CoPaw 做出贡献的朋友们: 贡献者 ================================================ FILE: SECURITY.md ================================================ # Security Policy If you believe you've found a security issue in CoPaw, please report it privately. ## Reporting Report vulnerabilities of the CoPaw repository: If you discover a security issue in CoPaw, please report it to us through the [Alibaba Security Response Center (ASRC)](https://security.alibaba.com/). ### Required in Reports 1. **Title** 2. **Severity assessment** 3. **Impact** 4. **Affected component** (e.g. channel adapter, skill, config loading) 5. **Technical reproduction steps** 6. **Demonstrated impact** (how it crosses a trust boundary, not just theoretical) 7. **Environment** (Python version, OS, how CoPaw is run) 8. **Remediation advice** (if you have suggestions) Reports without reproduction steps, demonstrated impact, and remediation advice will be deprioritized. Given the volume of scanner or AI-generated findings, we need vetted reports from researchers who understand the issues. ### Report Acceptance Gate For fastest triage, include all of the following: - Exact vulnerable path (file, function, and line range) on a current revision. - Tested version details (CoPaw version and/or commit SHA). - Reproducible PoC against latest `main` or latest released version. - Demonstrated impact tied to CoPaw's documented trust boundaries (see below). - For exposed-secret reports: proof the credential is CoPaw-owned or grants access to CoPaw-operated infrastructure/services. - Scope check explaining why the report is **not** covered by the Out of Scope section below. Reports that miss these requirements may be closed as `invalid` or `no-action`. ### Common False-Positive Patterns - Prompt-injection-only chains without a boundary bypass (prompt injection is out of scope). - Operator-intended local features (e.g. skills or commands the operator explicitly enabled) presented as remote injection. - Authorized user–triggered actions presented as privilege escalation (e.g. an allowed sender triggering a skill that writes to an allowed path). In this trust model, authorized user actions are trusted unless you demonstrate an auth/sandbox/boundary bypass. - Reports that only show a malicious skill executing privileged actions after a trusted operator installs/enables it. - Reports that assume per-user multi-tenant authorization on a shared CoPaw instance/config. - ReDoS/DoS claims that require trusted operator configuration input without a trust-boundary bypass. - Scanner-only claims against stale or nonexistent paths, or claims without a working repro. ### Duplicate Report Handling - Search existing advisories and issues before filing. - Include likely duplicate advisory IDs in your report when applicable. - Maintainers may close lower-quality or later duplicates in favor of the earliest high-quality canonical report. ## Alignment with `copaw init` The security model and recommended baseline in this document are **aligned with** what users see and accept during **`copaw init`**. The init flow shows a security notice that covers: single-operator boundary; shared delegated authority when multiple people message the same instance; restricting channels and users (allowlists); using separate config/credentials and OS users or hosts per trust boundary; least privilege and sandboxing; keeping secrets out of the working directory and skill-accessible paths; and reviewing config and skills regularly. When updating either this document or the `SECURITY_WARNING` in `src/copaw/cli/init_cmd.py`, keep the **concepts and recommendations** consistent so operators get the same message in both places. ## Security & Trust Security handling is owned by the CoPaw maintainers. For sensitive reports, use the private advisory or a private channel as above. ## Bug Bounties CoPaw is a community open-source project. There is no bug bounty program and no budget for paid reports. Please still disclose responsibly so we can fix issues quickly. The best way to help the project right now is by sending PRs or clear, reproducible reports. ## Operator Trust Model CoPaw does **not** model one instance as a multi-tenant, adversarial user boundary. - Authenticated callers to the same CoPaw instance (same config, same channel workspace) are treated as **trusted operators** for that instance. - Session identifiers and labels are routing/context controls, not per-user authorization boundaries. - If one operator can see or trigger what another operator can on the same instance, that is expected in this trust model. - **Recommended mode**: one user per machine/host (or per OS user), one CoPaw config for that user, and one or more agents/skills inside that instance. - If multiple users need CoPaw, use one host/OS user (or VPS) per user, or strict isolation; sharing one instance by mutually untrusted users is not the recommended default. - Skills run with the same privileges as the CoPaw process; only install and enable skills you trust. ## Trusted Skills Concept Skills/extensions are part of CoPaw's **trusted computing base** for an instance. - Installing or enabling a skill grants it the same trust level as local code running for that instance. - Skill behavior such as reading env/files or running host commands is expected inside this trust boundary. - Security reports must show a **boundary bypass** (e.g. unauthenticated skill load, allowlist/policy bypass, or path-safety bypass), not only malicious behavior from a trusted-installed skill. ## Out of Scope - Public internet exposure of CoPaw when the docs recommend against it. - Using CoPaw in ways that the docs recommend not to. - Deployments where mutually untrusted/adversarial operators share one CoPaw instance and config (e.g. reports expecting per-operator isolation for sessions, history, or similar). - **Prompt-injection-only** attacks (without a policy/auth/sandbox boundary bypass). - Reports that require write access to trusted local state (working directory, config, memory files) to achieve impact. - Reports where the only demonstrated impact is an already-authorized user intentionally invoking a skill or command that writes to an allowed path, without bypassing auth, sandbox, or another documented boundary. - Reports where the only claim is that a trusted-installed/enabled skill can execute with process/host privileges (documented trust model behavior). - Any report whose only claim is that an operator-enabled “dangerous” or break-glass option weakens defaults (these are explicit tradeoffs by design). - Reports that depend on trusted operator–supplied configuration to trigger availability impact (e.g. custom regex or patterns). These may still be fixed as defense-in-depth but are not security-boundary bypasses. - Exposed secrets that are third-party or user-controlled credentials (not CoPaw-owned and not granting access to CoPaw-operated infrastructure) without demonstrated CoPaw impact. - Scanner-only claims against stale or nonexistent paths, or without a working repro. ## Deployment Assumptions CoPaw security guidance assumes: - The host where CoPaw runs is within a trusted OS/admin boundary. - Anyone who can modify the CoPaw working directory and config (including `config.json`) is effectively a trusted operator. - A single instance shared by mutually untrusted people is **not** a recommended setup. Use separate configs and, at minimum, separate OS users or hosts per trust boundary. - Authenticated callers to the same instance are treated as trusted operators; session or context identifiers are routing controls, not per-user authorization boundaries. - Multiple instances can run on one machine, but the recommended model is clean per-user isolation (prefer one host/OS user per user). ## One-User Trust Model CoPaw's security model is **“personal assistant”** (one trusted operator, potentially many agents/skills), not “shared multi-tenant bus.” - If multiple people can message the same tool-enabled CoPaw instance (e.g. a shared channel workspace), they can all steer that agent within its granted permissions. - Session or memory scoping reduces context bleed but does **not** create per-user host authorization boundaries. - For mixed-trust or adversarial users, isolate by OS user/host and use separate config and credentials per boundary. - A company-shared agent can be valid when users are in the same trust boundary and the agent is strictly business-only. - For company-shared setups, use a dedicated machine/VM/container and dedicated accounts; avoid mixing personal data on that runtime. - If that host or environment is logged into personal accounts (e.g. personal password manager, personal cloud), you have collapsed the boundary and increased personal-data exposure risk. ## Agent and Model Assumptions - The model/agent is **not** a trusted principal. Assume prompt/content injection can manipulate behavior. - Security boundaries come from host/config trust, channel/user allowlists, tool policy, and what skills are enabled. - Prompt injection by itself is not a vulnerability report unless it crosses one of those boundaries. ## Working Directory and Config Trust Boundary The CoPaw working directory is treated as **trusted local operator state**. - If someone can edit working directory files or config, they have already crossed the trusted operator boundary. - Memory or context over those files is expected behavior, not a separate security boundary. - Example out-of-scope pattern: “attacker writes malicious content into workspace files, then the agent uses it.” If you need isolation between mutually untrusted users, split by OS user or host and run separate instances. - Keep secrets out of the working directory and skill-accessible paths; this is part of the baseline recommended in `copaw init`. ## Skills Trust Boundary Skills are loaded and run **in-process** (or under the same trust boundary) as the CoPaw runtime and are treated as trusted code. - Skills can execute with the same OS privileges as the CoPaw process. - Runtime helpers used by skills are convenience APIs, not a sandbox boundary. - Only install skills you trust, and restrict which skills are enabled in config where possible. ## Operational Guidance - **Channels and users**: Restrict which channels and users can trigger the agent; use allowlists where possible. - **Multi-user or shared inbox**: Use separate config/credentials and ideally separate OS users or hosts per trust boundary. - **Skills**: Run with least privilege; sandbox where you can; limit tool scope to what you need. - **Secrets**: Keep them out of the agent's working directory and skill-accessible paths. - **Model**: Use a capable model when the agent has tools or handles untrusted input. - **Review**: Review your config and skills regularly. For more operational and hardening guidance, see the [documentation](https://copaw.agentscope.io/) and any security-related docs linked from the repo. ## Runtime Requirements - **Python**: CoPaw requires a supported Python version (see [README](README.md)). Use a version with current security updates. - **Docker or restricted environments**: When running in containers, run as a non-root user when possible. Use read-only mounts where feasible and limit capabilities to what is needed. ================================================ FILE: console/eslint.config.js ================================================ import js from "@eslint/js"; import globals from "globals"; import reactHooks from "eslint-plugin-react-hooks"; import reactRefresh from "eslint-plugin-react-refresh"; import tseslint from "typescript-eslint"; export default tseslint.config( { ignores: ["dist"] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ["**/*.{ts,tsx}"], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, plugins: { "react-hooks": reactHooks, "react-refresh": reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, "react-refresh/only-export-components": [ "warn", { allowConstantExport: true }, ], }, }, ); ================================================ FILE: console/index.html ================================================ CoPaw Console
================================================ FILE: console/package.json ================================================ { "name": "copaw-console", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite --host", "build": "tsc -b && vite build", "build:prod": "tsc -b && vite build --mode production", "build:test": "tsc -b && vite build --mode test", "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint .", "preview": "vite preview", "preview:prod": "vite preview --mode production", "preview:test": "vite preview --mode test" }, "dependencies": { "@agentscope-ai/chat": "^1.1.51", "@agentscope-ai/design": "^1.0.14", "@agentscope-ai/icons": "^1.0.46", "@ant-design/x-markdown": "^2.2.2", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "ahooks": "^3.9.6", "antd": "^5.29.1", "antd-style": "^3.7.1", "i18next": "^25.8.4", "lucide-react": "^0.562.0", "react": "^18", "react-dom": "^18", "react-i18next": "^16.5.4", "react-markdown": "^10.1.0", "react-router-dom": "^7.13.0", "remark-gfm": "^4.0.1" }, "devDependencies": { "@eslint/js": "^9.25.0", "@types/i18next": "^12.1.0", "@types/node": "^25.0.3", "@types/react": "^18", "@types/react-dom": "^18", "@vitejs/plugin-react": "^4.4.1", "eslint": "^9.25.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", "less": "^4.5.1", "prettier": "3.0.0", "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", "vite": "^6.3.5" }, "repository": "https://github.com/agentscope-ai/agentscope-runtime.git" } ================================================ FILE: console/src/App.tsx ================================================ import { createGlobalStyle } from "antd-style"; import { ConfigProvider, bailianTheme } from "@agentscope-ai/design"; import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import zhCN from "antd/locale/zh_CN"; import enUS from "antd/locale/en_US"; import jaJP from "antd/locale/ja_JP"; import ruRU from "antd/locale/ru_RU"; import type { Locale } from "antd/es/locale"; import { theme as antdTheme } from "antd"; import dayjs from "dayjs"; import "dayjs/locale/zh-cn"; import "dayjs/locale/ja"; import "dayjs/locale/ru"; import MainLayout from "./layouts/MainLayout"; import { ThemeProvider, useTheme } from "./contexts/ThemeContext"; import LoginPage from "./pages/Login"; import { authApi } from "./api/modules/auth"; import { getApiUrl, getApiToken, clearAuthToken } from "./api/config"; import "./styles/layout.css"; import "./styles/form-override.css"; const antdLocaleMap: Record = { zh: zhCN, en: enUS, ja: jaJP, ru: ruRU, }; const dayjsLocaleMap: Record = { zh: "zh-cn", en: "en", ja: "ja", ru: "ru", }; const GlobalStyle = createGlobalStyle` * { margin: 0; box-sizing: border-box; } `; function AuthGuard({ children }: { children: React.ReactNode }) { const [status, setStatus] = useState<"loading" | "auth-required" | "ok">( "loading", ); useEffect(() => { let cancelled = false; (async () => { try { const res = await authApi.getStatus(); if (cancelled) return; if (!res.enabled) { setStatus("ok"); return; } const token = getApiToken(); if (!token) { setStatus("auth-required"); return; } try { const r = await fetch(getApiUrl("/auth/verify"), { headers: { Authorization: `Bearer ${token}` }, }); if (cancelled) return; if (r.ok) { setStatus("ok"); } else { clearAuthToken(); setStatus("auth-required"); } } catch { if (!cancelled) { clearAuthToken(); setStatus("auth-required"); } } } catch { if (!cancelled) setStatus("ok"); } })(); return () => { cancelled = true; }; }, []); if (status === "loading") return null; if (status === "auth-required") return ( ); return <>{children}; } function getRouterBasename(pathname: string): string | undefined { return /^\/console(?:\/|$)/.test(pathname) ? "/console" : undefined; } function AppInner() { const basename = getRouterBasename(window.location.pathname); const { i18n } = useTranslation(); const { isDark } = useTheme(); const lang = i18n.resolvedLanguage || i18n.language || "en"; const [antdLocale, setAntdLocale] = useState( antdLocaleMap[lang] ?? enUS, ); useEffect(() => { const handleLanguageChanged = (lng: string) => { const shortLng = lng.split("-")[0]; setAntdLocale(antdLocaleMap[shortLng] ?? enUS); dayjs.locale(dayjsLocaleMap[shortLng] ?? "en"); }; // Set initial dayjs locale dayjs.locale(dayjsLocaleMap[lang.split("-")[0]] ?? "en"); i18n.on("languageChanged", handleLanguageChanged); return () => { i18n.off("languageChanged", handleLanguageChanged); }; }, [i18n]); return ( } /> } /> ); } function App() { return ( ); } export default App; ================================================ FILE: console/src/api/config.ts ================================================ declare const BASE_URL: string; declare const TOKEN: string; const AUTH_TOKEN_KEY = "copaw_auth_token"; /** * Get the full API URL with /api prefix * @param path - API path (e.g., "/models", "/skills") * @returns Full API URL (e.g., "http://localhost:8088/api/models" or "/api/models") */ export function getApiUrl(path: string): string { const base = BASE_URL || ""; const apiPrefix = "/api"; const normalizedPath = path.startsWith("/") ? path : `/${path}`; return `${base}${apiPrefix}${normalizedPath}`; } /** * Get the API token - checks localStorage first (auth login), * then falls back to the build-time TOKEN constant. * @returns API token string or empty string */ export function getApiToken(): string { const stored = localStorage.getItem(AUTH_TOKEN_KEY); if (stored) return stored; return typeof TOKEN !== "undefined" ? TOKEN : ""; } /** * Store the auth token in localStorage after login. */ export function setAuthToken(token: string): void { localStorage.setItem(AUTH_TOKEN_KEY, token); } /** * Remove the auth token from localStorage (logout / 401). */ export function clearAuthToken(): void { localStorage.removeItem(AUTH_TOKEN_KEY); } ================================================ FILE: console/src/api/index.ts ================================================ export * from "./types"; export { request } from "./request"; export { getApiUrl, getApiToken } from "./config"; import { rootApi } from "./modules/root"; import { channelApi } from "./modules/channel"; import { heartbeatApi } from "./modules/heartbeat"; import { cronJobApi } from "./modules/cronjob"; import { chatApi, sessionApi } from "./modules/chat"; import { envApi } from "./modules/env"; import { providerApi } from "./modules/provider"; import { skillApi } from "./modules/skill"; import { agentApi } from "./modules/agent"; import { agentsApi } from "./modules/agents"; import { workspaceApi } from "./modules/workspace"; import { localModelApi } from "./modules/localModel"; import { ollamaModelApi } from "./modules/ollamaModel"; import { mcpApi } from "./modules/mcp"; import { tokenUsageApi } from "./modules/tokenUsage"; import { toolsApi } from "./modules/tools"; import { securityApi } from "./modules/security"; import { userTimezoneApi } from "./modules/userTimezone"; export const api = { // Root ...rootApi, // Channels ...channelApi, // Heartbeat ...heartbeatApi, // Cron Jobs ...cronJobApi, // Chats ...chatApi, // Sessions(Legacy aliases) ...sessionApi, // Environment Variables ...envApi, // Providers ...providerApi, // Agent ...agentApi, // Skills ...skillApi, // Workspace ...workspaceApi, // Local Models ...localModelApi, // Ollama Models ...ollamaModelApi, // MCP Clients ...mcpApi, // Token Usage ...tokenUsageApi, // Tools ...toolsApi, // Security ...securityApi, // User Timezone ...userTimezoneApi, }; export default api; // Export individual APIs for direct access export { agentsApi }; ================================================ FILE: console/src/api/modules/agent.ts ================================================ import { request } from "../request"; import type { AgentRequest, AgentsRunningConfig } from "../types"; // Agent API export const agentApi = { agentRoot: () => request("/agent/"), healthCheck: () => request("/agent/health"), agentApi: (body: AgentRequest) => request("/agent/process", { method: "POST", body: JSON.stringify(body), }), getProcessStatus: () => request("/agent/admin/status"), shutdownSimple: () => request("/agent/shutdown", { method: "POST", }), shutdown: () => request("/agent/admin/shutdown", { method: "POST", }), getAgentRunningConfig: () => request("/agent/running-config"), updateAgentRunningConfig: (config: AgentsRunningConfig) => request("/agent/running-config", { method: "PUT", body: JSON.stringify(config), }), getAgentLanguage: () => request<{ language: string }>("/agent/language"), updateAgentLanguage: (language: string) => request<{ language: string; copied_files: string[] }>("/agent/language", { method: "PUT", body: JSON.stringify({ language }), }), getAudioMode: () => request<{ audio_mode: string }>("/agent/audio-mode"), updateAudioMode: (audio_mode: string) => request<{ audio_mode: string }>("/agent/audio-mode", { method: "PUT", body: JSON.stringify({ audio_mode }), }), getTranscriptionProviders: () => request<{ providers: { id: string; name: string; available: boolean }[]; configured_provider_id: string; }>("/agent/transcription-providers"), updateTranscriptionProvider: (provider_id: string) => request<{ provider_id: string }>("/agent/transcription-provider", { method: "PUT", body: JSON.stringify({ provider_id }), }), getTranscriptionProviderType: () => request<{ transcription_provider_type: string }>( "/agent/transcription-provider-type", ), updateTranscriptionProviderType: (transcription_provider_type: string) => request<{ transcription_provider_type: string }>( "/agent/transcription-provider-type", { method: "PUT", body: JSON.stringify({ transcription_provider_type }), }, ), getLocalWhisperStatus: () => request<{ available: boolean; ffmpeg_installed: boolean; whisper_installed: boolean; }>("/agent/local-whisper-status"), }; ================================================ FILE: console/src/api/modules/agents.ts ================================================ import { request } from "../request"; import type { AgentListResponse, AgentProfileConfig, CreateAgentRequest, AgentProfileRef, } from "../types/agents"; import type { MdFileInfo, MdFileContent } from "../types/workspace"; // Multi-agent management API export const agentsApi = { // List all agents listAgents: () => request("/agents"), // Get agent details getAgent: (agentId: string) => request(`/agents/${agentId}`), // Create new agent createAgent: (agent: CreateAgentRequest) => request("/agents", { method: "POST", body: JSON.stringify(agent), }), // Update agent configuration updateAgent: (agentId: string, agent: AgentProfileConfig) => request(`/agents/${agentId}`, { method: "PUT", body: JSON.stringify(agent), }), // Delete agent deleteAgent: (agentId: string) => request<{ success: boolean; agent_id: string }>(`/agents/${agentId}`, { method: "DELETE", }), // Agent workspace files listAgentFiles: (agentId: string) => request(`/agents/${agentId}/files`), readAgentFile: (agentId: string, filename: string) => request( `/agents/${agentId}/files/${encodeURIComponent(filename)}`, ), writeAgentFile: (agentId: string, filename: string, content: string) => request<{ written: boolean; filename: string }>( `/agents/${agentId}/files/${encodeURIComponent(filename)}`, { method: "PUT", body: JSON.stringify({ content }), }, ), // Agent memory files listAgentMemory: (agentId: string) => request(`/agents/${agentId}/memory`), }; ================================================ FILE: console/src/api/modules/auth.ts ================================================ import { getApiUrl } from "../config"; export interface LoginResponse { token: string; username: string; message?: string; } export interface AuthStatusResponse { enabled: boolean; has_users: boolean; } export const authApi = { login: async (username: string, password: string): Promise => { const res = await fetch(getApiUrl("/auth/login"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.detail || "Login failed"); } return res.json(); }, register: async ( username: string, password: string, ): Promise => { const res = await fetch(getApiUrl("/auth/register"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.detail || "Registration failed"); } return res.json(); }, getStatus: async (): Promise => { const res = await fetch(getApiUrl("/auth/status")); if (!res.ok) throw new Error("Failed to check auth status"); return res.json(); }, }; ================================================ FILE: console/src/api/modules/channel.ts ================================================ import { request } from "../request"; import type { ChannelConfig, SingleChannelConfig } from "../types"; export const channelApi = { listChannelTypes: () => request("/config/channels/types"), listChannels: () => request("/config/channels"), updateChannels: (body: ChannelConfig) => request("/config/channels", { method: "PUT", body: JSON.stringify(body), }), getChannelConfig: (channelName: string) => request( `/config/channels/${encodeURIComponent(channelName)}`, ), updateChannelConfig: (channelName: string, body: SingleChannelConfig) => request( `/config/channels/${encodeURIComponent(channelName)}`, { method: "PUT", body: JSON.stringify(body), }, ), }; ================================================ FILE: console/src/api/modules/chat.ts ================================================ import { request } from "../request"; import { getApiUrl } from "../config"; import type { ChatSpec, ChatHistory, ChatDeleteResponse, Session, } from "../types"; /** Response from POST /console/upload. url = filename only; agent_id from header. */ export interface ChatUploadResponse { url: string; file_name: string; stored_name?: string; } const CONSOLE_FILES_PREFIX = "/console/files"; function buildChatUploadHeaders(): HeadersInit { const headers: Record = {}; const token = localStorage.getItem("copaw_auth_token"); if (token) headers.Authorization = `Bearer ${token}`; try { const agentStorage = localStorage.getItem("copaw-agent-storage"); if (agentStorage) { const parsed = JSON.parse(agentStorage); const selectedAgent = parsed?.state?.selectedAgent; if (selectedAgent) headers["X-Agent-Id"] = selectedAgent; } } catch { // ignore } return headers; } function getSelectedAgentId(): string { try { const agentStorage = localStorage.getItem("copaw-agent-storage"); if (agentStorage) { const parsed = JSON.parse(agentStorage); const id = parsed?.state?.selectedAgent; if (id) return id; } } catch { // ignore } return ""; } export const chatApi = { /** Upload a file for chat attachment. Returns URL path for content. */ uploadFile: async (file: File): Promise => { const formData = new FormData(); formData.append("file", file); const response = await fetch(getApiUrl("/console/upload"), { method: "POST", headers: buildChatUploadHeaders(), body: formData, }); if (!response.ok) { const text = await response.text().catch(() => ""); throw new Error( `Upload failed: ${response.status} ${response.statusText}${ text ? ` - ${text}` : "" }`, ); } return response.json(); }, /** Build full API URL for a console file. Backend returns filename only; agent_id from header/context (selectedAgent). */ fileUrl: (filename: string): string => { if (!filename) return ""; if (filename.startsWith("http://") || filename.startsWith("https://")) return filename; const agentId = getSelectedAgentId() || "default"; const path = `${CONSOLE_FILES_PREFIX}/${agentId}/${filename.replace( /^\/+/, "", )}`; return getApiUrl(path); }, listChats: (params?: { user_id?: string; channel?: string }) => { const searchParams = new URLSearchParams(); if (params?.user_id) searchParams.append("user_id", params.user_id); if (params?.channel) searchParams.append("channel", params.channel); const query = searchParams.toString(); return request(`/chats${query ? `?${query}` : ""}`); }, createChat: (chat: Partial) => request("/chats", { method: "POST", body: JSON.stringify(chat), }), getChat: (chatId: string) => request(`/chats/${encodeURIComponent(chatId)}`), updateChat: (chatId: string, chat: Partial) => request(`/chats/${encodeURIComponent(chatId)}`, { method: "PUT", body: JSON.stringify(chat), }), deleteChat: (chatId: string) => request(`/chats/${encodeURIComponent(chatId)}`, { method: "DELETE", }), batchDeleteChats: (chatIds: string[]) => request<{ success: boolean; deleted_count: number }>( "/chats/batch-delete", { method: "POST", body: JSON.stringify(chatIds), }, ), /** Stop a running console chat (only stop when user clicks stop). chat_id = ChatSpec.id */ stopConsoleChat: (chatId: string) => request<{ stopped: boolean }>( `/console/chat/stop?chat_id=${encodeURIComponent(chatId)}`, { method: "POST" }, ), }; export const sessionApi = { listSessions: (params?: { user_id?: string; channel?: string }) => { const searchParams = new URLSearchParams(); if (params?.user_id) searchParams.append("user_id", params.user_id); if (params?.channel) searchParams.append("channel", params.channel); const query = searchParams.toString(); return request(`/chats${query ? `?${query}` : ""}`); }, getSession: (sessionId: string) => request(`/chats/${encodeURIComponent(sessionId)}`), deleteSession: (sessionId: string) => request(`/chats/${encodeURIComponent(sessionId)}`, { method: "DELETE", }), createSession: (session: Partial) => request("/chats", { method: "POST", body: JSON.stringify(session), }), updateSession: (sessionId: string, session: Partial) => request(`/chats/${encodeURIComponent(sessionId)}`, { method: "PUT", body: JSON.stringify(session), }), batchDeleteSessions: (sessionIds: string[]) => request<{ success: boolean; deleted_count: number }>( "/chats/batch-delete", { method: "POST", body: JSON.stringify(sessionIds), }, ), }; ================================================ FILE: console/src/api/modules/console.ts ================================================ import { request } from "../request"; export interface PushMessage { id: string; text: string; } export const consoleApi = { getPushMessages: () => request<{ messages: PushMessage[] }>("/console/push-messages"), }; ================================================ FILE: console/src/api/modules/cronjob.ts ================================================ import { request } from "../request"; import type { CronJobSpecInput, CronJobSpecOutput, CronJobView, } from "../types"; export const cronJobApi = { listCronJobs: () => request("/cron/jobs"), createCronJob: (spec: CronJobSpecInput) => request("/cron/jobs", { method: "POST", body: JSON.stringify(spec), }), getCronJob: (jobId: string) => request(`/cron/jobs/${encodeURIComponent(jobId)}`), replaceCronJob: (jobId: string, spec: CronJobSpecInput) => request(`/cron/jobs/${encodeURIComponent(jobId)}`, { method: "PUT", body: JSON.stringify(spec), }), deleteCronJob: (jobId: string) => request(`/cron/jobs/${encodeURIComponent(jobId)}`, { method: "DELETE", }), pauseCronJob: (jobId: string) => request(`/cron/jobs/${encodeURIComponent(jobId)}/pause`, { method: "POST", }), resumeCronJob: (jobId: string) => request(`/cron/jobs/${encodeURIComponent(jobId)}/resume`, { method: "POST", }), runCronJob: (jobId: string) => request(`/cron/jobs/${encodeURIComponent(jobId)}/run`, { method: "POST", }), triggerCronJob: (jobId: string) => request(`/cron/jobs/${encodeURIComponent(jobId)}/run`, { method: "POST", }), getCronJobState: (jobId: string) => request(`/cron/jobs/${encodeURIComponent(jobId)}/state`), }; ================================================ FILE: console/src/api/modules/env.ts ================================================ import { request } from "../request"; import type { EnvVar } from "../types"; export const envApi = { listEnvs: () => request("/envs"), /** Batch save – full replacement of all env vars. */ saveEnvs: (envs: Record) => request("/envs", { method: "PUT", body: JSON.stringify(envs), }), deleteEnv: (key: string) => request(`/envs/${encodeURIComponent(key)}`, { method: "DELETE", }), }; ================================================ FILE: console/src/api/modules/heartbeat.ts ================================================ import { request } from "../request"; import type { HeartbeatConfig } from "../types/heartbeat"; export const heartbeatApi = { getHeartbeatConfig: () => request("/config/heartbeat"), updateHeartbeatConfig: (body: HeartbeatConfig) => request("/config/heartbeat", { method: "PUT", body: JSON.stringify(body), }), }; ================================================ FILE: console/src/api/modules/localModel.ts ================================================ import { request } from "../request"; import type { LocalModelResponse, DownloadModelRequest, DownloadTaskResponse, } from "../types"; export const localModelApi = { listLocalModels: (backend?: string) => { const params = backend ? `?backend=${encodeURIComponent(backend)}` : ""; return request(`/local-models${params}`); }, downloadModel: (body: DownloadModelRequest) => request("/local-models/download", { method: "POST", body: JSON.stringify(body), }), getDownloadStatus: (backend?: string) => { const params = backend ? `?backend=${encodeURIComponent(backend)}` : ""; return request( `/local-models/download-status${params}`, ); }, cancelDownload: (taskId: string) => request<{ status: string; task_id: string }>( `/local-models/cancel-download/${encodeURIComponent(taskId)}`, { method: "POST" }, ), deleteLocalModel: (modelId: string) => request<{ status: string; model_id: string }>( `/local-models/${encodeURIComponent(modelId)}`, { method: "DELETE" }, ), }; ================================================ FILE: console/src/api/modules/mcp.ts ================================================ import { request } from "../request"; import type { MCPClientInfo, MCPClientCreateRequest, MCPClientUpdateRequest, } from "../types"; export const mcpApi = { /** * List all MCP clients */ listMCPClients: () => request("/mcp"), /** * Get details of a specific MCP client */ getMCPClient: (clientKey: string) => request(`/mcp/${encodeURIComponent(clientKey)}`), /** * Create a new MCP client */ createMCPClient: (body: MCPClientCreateRequest) => request("/mcp", { method: "POST", body: JSON.stringify(body), }), /** * Update an existing MCP client */ updateMCPClient: (clientKey: string, body: MCPClientUpdateRequest) => request(`/mcp/${encodeURIComponent(clientKey)}`, { method: "PUT", body: JSON.stringify(body), }), /** * Toggle MCP client enabled status */ toggleMCPClient: (clientKey: string) => request(`/mcp/${encodeURIComponent(clientKey)}/toggle`, { method: "PATCH", }), /** * Delete an MCP client */ deleteMCPClient: (clientKey: string) => request<{ message: string }>(`/mcp/${encodeURIComponent(clientKey)}`, { method: "DELETE", }), }; ================================================ FILE: console/src/api/modules/ollamaModel.ts ================================================ import { request } from "../request"; import type { OllamaModelResponse, OllamaDownloadRequest, OllamaDownloadTaskResponse, } from "../types"; export const ollamaModelApi = { listOllamaModels: () => request("/ollama-models"), downloadOllamaModel: (body: OllamaDownloadRequest) => request("/ollama-models/download", { method: "POST", body: JSON.stringify(body), }), getOllamaDownloadStatus: () => request("/ollama-models/download-status"), cancelOllamaDownload: (taskId: string) => request<{ status: string; task_id: string }>( `/ollama-models/download/${encodeURIComponent(taskId)}`, { method: "DELETE" }, ), deleteOllamaModel: (name: string) => request<{ status: string; name: string }>( `/ollama-models/${encodeURIComponent(name)}`, { method: "DELETE" }, ), }; ================================================ FILE: console/src/api/modules/provider.ts ================================================ import { request } from "../request"; import type { ProviderInfo, ProviderConfigRequest, ActiveModelsInfo, ModelSlotRequest, CreateCustomProviderRequest, AddModelRequest, TestConnectionResponse, TestProviderRequest, TestModelRequest, DiscoverModelsResponse, } from "../types"; export const providerApi = { listProviders: () => request("/models"), configureProvider: (providerId: string, body: ProviderConfigRequest) => request(`/models/${encodeURIComponent(providerId)}/config`, { method: "PUT", body: JSON.stringify(body), }), getActiveModels: () => request("/models/active"), setActiveLlm: (body: ModelSlotRequest) => request("/models/active", { method: "PUT", body: JSON.stringify(body), }), /* ---- Custom provider CRUD ---- */ createCustomProvider: (body: CreateCustomProviderRequest) => request("/models/custom-providers", { method: "POST", body: JSON.stringify(body), }), deleteCustomProvider: (providerId: string) => request( `/models/custom-providers/${encodeURIComponent(providerId)}`, { method: "DELETE" }, ), /* ---- Model CRUD (works for both built-in and custom providers) ---- */ addModel: (providerId: string, body: AddModelRequest) => request(`/models/${encodeURIComponent(providerId)}/models`, { method: "POST", body: JSON.stringify(body), }), removeModel: (providerId: string, modelId: string) => request( `/models/${encodeURIComponent(providerId)}/models/${encodeURIComponent( modelId, )}`, { method: "DELETE" }, ), /* ---- Test Connection ---- */ testProviderConnection: (providerId: string, body?: TestProviderRequest) => request( `/models/${encodeURIComponent(providerId)}/test`, { method: "POST", body: body ? JSON.stringify(body) : undefined, }, ), testModelConnection: (providerId: string, body: TestModelRequest) => request( `/models/${encodeURIComponent(providerId)}/models/test`, { method: "POST", body: JSON.stringify(body), }, ), discoverModels: (providerId: string, body?: TestProviderRequest) => request( `/models/${encodeURIComponent(providerId)}/discover`, { method: "POST", body: body ? JSON.stringify(body) : undefined, }, ), }; ================================================ FILE: console/src/api/modules/root.ts ================================================ import { request } from "../request"; // Root API export const rootApi = { readRoot: () => request("/"), getVersion: () => request<{ version: string }>("/version"), }; ================================================ FILE: console/src/api/modules/security.ts ================================================ import { request } from "../request"; export interface ToolGuardRule { id: string; tools: string[]; params: string[]; category: string; severity: string; patterns: string[]; exclude_patterns: string[]; description: string; remediation: string; } export interface ToolGuardConfig { enabled: boolean; guarded_tools: string[] | null; denied_tools: string[]; custom_rules: ToolGuardRule[]; disabled_rules: string[]; } // ── Skill Scanner types ──────────────────────────────────────────── export interface SkillScannerWhitelistEntry { skill_name: string; content_hash: string; added_at: string; } export type SkillScannerMode = "block" | "warn" | "off"; export interface SkillScannerConfig { mode: SkillScannerMode; timeout: number; whitelist: SkillScannerWhitelistEntry[]; } export interface BlockedSkillFinding { severity: string; title: string; description: string; file_path: string; line_number: number | null; rule_id: string; } export interface BlockedSkillRecord { skill_name: string; blocked_at: string; max_severity: string; findings: BlockedSkillFinding[]; content_hash: string; action: "blocked" | "warned"; } export interface SecurityScanErrorResponse { type: "security_scan_failed"; detail: string; skill_name: string; max_severity: string; findings: BlockedSkillFinding[]; } export const securityApi = { // ── Tool Guard ────────────────────────────────────────────────── getToolGuard: () => request("/config/security/tool-guard"), updateToolGuard: (body: ToolGuardConfig) => request("/config/security/tool-guard", { method: "PUT", body: JSON.stringify(body), }), getBuiltinRules: () => request("/config/security/tool-guard/builtin-rules"), // ── Skill Scanner ─────────────────────────────────────────────── getSkillScanner: () => request("/config/security/skill-scanner"), updateSkillScanner: (body: SkillScannerConfig) => request("/config/security/skill-scanner", { method: "PUT", body: JSON.stringify(body), }), getBlockedHistory: () => request( "/config/security/skill-scanner/blocked-history", ), clearBlockedHistory: () => request<{ cleared: boolean }>( "/config/security/skill-scanner/blocked-history", { method: "DELETE" }, ), removeBlockedEntry: (index: number) => request<{ removed: boolean }>( `/config/security/skill-scanner/blocked-history/${index}`, { method: "DELETE" }, ), addToWhitelist: (skillName: string, contentHash: string = "") => request<{ whitelisted: boolean; skill_name: string }>( "/config/security/skill-scanner/whitelist", { method: "POST", body: JSON.stringify({ skill_name: skillName, content_hash: contentHash, }), }, ), removeFromWhitelist: (skillName: string) => request<{ removed: boolean; skill_name: string }>( `/config/security/skill-scanner/whitelist/${encodeURIComponent( skillName, )}`, { method: "DELETE" }, ), }; ================================================ FILE: console/src/api/modules/skill.ts ================================================ import { request } from "../request"; import { getApiUrl, getApiToken } from "../config"; import type { HubSkillSpec, SkillSpec } from "../types"; // Declare BASE_URL as global (injected by Vite) declare const BASE_URL: string; // Get the API base URL for streaming requests function getStreamApiUrl(): string { const base = typeof BASE_URL === "string" ? BASE_URL : ""; return `${base}/api`; } function buildHeaders(): HeadersInit { const headers: Record = {}; const token = getApiToken(); if (token) { headers["Authorization"] = `Bearer ${token}`; } try { const agentStorage = localStorage.getItem("copaw-agent-storage"); if (agentStorage) { const parsed = JSON.parse(agentStorage); const selectedAgent = parsed?.state?.selectedAgent; if (selectedAgent) { headers["X-Agent-Id"] = selectedAgent; } } } catch (error) { console.warn("Failed to get selected agent from storage:", error); } return headers; } export const skillApi = { listSkills: () => request("/skills"), createSkill: (skillName: string, content: string) => request>("/skills", { method: "POST", body: JSON.stringify({ name: skillName, content: content, }), }), enableSkill: (skillName: string) => request(`/skills/${encodeURIComponent(skillName)}/enable`, { method: "POST", }), disableSkill: (skillName: string) => request(`/skills/${encodeURIComponent(skillName)}/disable`, { method: "POST", }), batchEnableSkills: (skillNames: string[]) => request("/skills/batch-enable", { method: "POST", body: JSON.stringify(skillNames), }), deleteSkill: (skillName: string) => request<{ deleted: boolean }>(`/skills/${encodeURIComponent(skillName)}`, { method: "DELETE", }), searchHubSkills: (query: string, limit = 20) => request( `/skills/hub/search?q=${encodeURIComponent(query)}&limit=${limit}`, ), installHubSkill: ( payload: { bundle_url: string; version?: string; enable?: boolean; overwrite?: boolean; }, options?: { signal?: AbortSignal }, ) => request<{ installed: boolean; name: string; enabled: boolean; source_url: string; }>("/skills/hub/install", { method: "POST", body: JSON.stringify(payload), signal: options?.signal, }), startHubSkillInstall: (payload: { bundle_url: string; version?: string; enable?: boolean; overwrite?: boolean; }) => request<{ task_id: string; bundle_url: string; version: string; enable: boolean; overwrite: boolean; status: "pending" | "importing" | "completed" | "failed" | "cancelled"; error: string | null; result: { installed: boolean; name: string; enabled: boolean; source_url: string; } | null; created_at: number; updated_at: number; }>("/skills/hub/install/start", { method: "POST", body: JSON.stringify(payload), }), getHubSkillInstallStatus: (taskId: string) => request<{ task_id: string; bundle_url: string; version: string; enable: boolean; overwrite: boolean; status: "pending" | "importing" | "completed" | "failed" | "cancelled"; error: string | null; result: { installed: boolean; name: string; enabled: boolean; source_url: string; } | null; created_at: number; updated_at: number; }>(`/skills/hub/install/status/${encodeURIComponent(taskId)}`), cancelHubSkillInstall: (taskId: string) => request<{ task_id: string; status: string }>( `/skills/hub/install/cancel/${encodeURIComponent(taskId)}`, { method: "POST", }, ), // Stream optimize skill with SSE (supports abort via signal) streamOptimizeSkill: async function ( content: string, onChunk: (text: string) => void, signal: AbortSignal, language: string = "en", ): Promise { const apiUrl = getStreamApiUrl(); const response = await fetch(`${apiUrl}/skills/ai/optimize/stream`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ content, language }), signal, }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const reader = response.body?.getReader(); if (!reader) { throw new Error("No reader available"); } const decoder = new TextDecoder(); let buffer = ""; try { while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); for (let i = 0; i < lines.length - 1; i++) { const line = lines[i].trim(); if (line.startsWith("data: ")) { const data = line.slice(6); try { const parsed = JSON.parse(data); if (parsed.text) { onChunk(parsed.text); } else if (parsed.error) { throw new Error(parsed.error); } else if (parsed.done) { return; } } catch { // Skip invalid JSON } } } buffer = lines[lines.length - 1]; } } finally { reader.releaseLock(); } }, uploadSkill: async ( file: File, options?: { enable?: boolean; overwrite?: boolean }, ): Promise<{ imported: string[]; count: number; enabled: boolean }> => { const formData = new FormData(); formData.append("file", file); const params = new URLSearchParams(); if (options?.enable !== undefined) { params.set("enable", String(options.enable)); } if (options?.overwrite !== undefined) { params.set("overwrite", String(options.overwrite)); } const qs = params.toString(); const url = getApiUrl(`/skills/upload${qs ? `?${qs}` : ""}`); const headers = buildHeaders(); const response = await fetch(url, { method: "POST", headers, body: formData, }); if (!response.ok) { const errorText = await response.text(); throw new Error( `Upload failed: ${response.status} ${response.statusText} - ${errorText}`, ); } return await response.json(); }, }; ================================================ FILE: console/src/api/modules/tokenUsage.ts ================================================ import { request } from "../request"; import type { TokenUsageSummary } from "../types/tokenUsage"; export interface GetTokenUsageParams { start_date: string; end_date: string; } function buildQuery(params: GetTokenUsageParams): string { const search = new URLSearchParams({ start_date: params.start_date, end_date: params.end_date, }); return `?${search.toString()}`; } export const tokenUsageApi = { getTokenUsage: (params: GetTokenUsageParams) => request(`/token-usage${buildQuery(params)}`), }; ================================================ FILE: console/src/api/modules/tools.ts ================================================ import { request } from "../request"; export interface ToolInfo { name: string; enabled: boolean; description: string; } export const toolsApi = { /** * List all built-in tools */ listTools: () => request("/tools"), /** * Toggle tool enabled status */ toggleTool: (toolName: string) => request(`/tools/${encodeURIComponent(toolName)}/toggle`, { method: "PATCH", }), }; ================================================ FILE: console/src/api/modules/userTimezone.ts ================================================ import { request } from "../request"; export interface UserTimezoneConfig { timezone: string; } export const userTimezoneApi = { getUserTimezone: () => request("/config/user-timezone"), updateUserTimezone: (timezone: string) => request("/config/user-timezone", { method: "PUT", body: JSON.stringify({ timezone }), }), }; ================================================ FILE: console/src/api/modules/workspace.ts ================================================ import { request } from "../request"; import { getApiUrl, getApiToken } from "../config"; import type { MdFileInfo, MdFileContent, DailyMemoryFile } from "../types"; function buildHeaders(): HeadersInit { const headers: Record = {}; const token = getApiToken(); if (token) { headers["Authorization"] = `Bearer ${token}`; } try { const agentStorage = localStorage.getItem("copaw-agent-storage"); if (agentStorage) { const parsed = JSON.parse(agentStorage); const selectedAgent = parsed?.state?.selectedAgent; if (selectedAgent) { headers["X-Agent-Id"] = selectedAgent; } } } catch (error) { console.warn("Failed to get selected agent from storage:", error); } return headers; } function getSelectedAgentId(): string { try { const agentStorage = localStorage.getItem("copaw-agent-storage"); if (agentStorage) { const parsed = JSON.parse(agentStorage); const selectedAgent = parsed?.state?.selectedAgent; if (selectedAgent) { return selectedAgent; } } } catch (error) { console.warn("Failed to get selected agent from storage:", error); } return "default"; } function generateFallbackFilename(): string { const agentId = getSelectedAgentId(); const now = new Date(); const timestamp = now .toISOString() .replace(/[-:]/g, "") .replace(/\..+/, "") .replace("T", "_") .slice(0, 15); // YYYYMMDD_HHMMSS return `copaw_workspace_${agentId}_${timestamp}.zip`; } export interface WorkspaceDownloadResult { blob: Blob; filename: string; } export const workspaceApi = { listFiles: () => request("/agent/files").then((files) => files.map((file) => ({ ...file, updated_at: new Date(file.modified_time).getTime(), })), ), loadFile: (fileName: string) => request(`/agent/files/${encodeURIComponent(fileName)}`), saveFile: (fileName: string, content: string) => request>( `/agent/files/${encodeURIComponent(fileName)}`, { method: "PUT", body: JSON.stringify({ content }), }, ), // Workspace package download downloadWorkspace: async (): Promise => { const response = await fetch(getApiUrl("/workspace/download"), { method: "GET", headers: buildHeaders(), }); if (!response.ok) { throw new Error( `Workspace download failed: ${response.status} ${response.statusText}`, ); } const blob = await response.blob(); // Extract filename from Content-Disposition header const disposition = response.headers.get("Content-Disposition"); let filename: string; if (disposition) { const filenameMatch = disposition.match(/filename="(.+?)"/); if (filenameMatch && filenameMatch[1]) { filename = filenameMatch[1]; } else { filename = generateFallbackFilename(); } } else { filename = generateFallbackFilename(); } return { blob, filename }; }, // File upload functionality uploadFile: async ( file: File, ): Promise<{ success: boolean; message: string }> => { const formData = new FormData(); formData.append("file", file); const response = await fetch(getApiUrl("/workspace/upload"), { method: "POST", headers: buildHeaders(), body: formData, }); if (!response.ok) { const errorText = await response.text(); throw new Error( `Upload failed: ${response.status} ${response.statusText} - ${errorText}`, ); } return await response.json(); }, listDailyMemory: () => request("/agent/memory").then((files) => files.map((file) => { const date = file.filename.replace(".md", ""); return { ...file, date, updated_at: new Date(file.modified_time).getTime(), } as DailyMemoryFile; }), ), loadDailyMemory: (date: string) => request(`/agent/memory/${encodeURIComponent(date)}.md`), saveDailyMemory: (date: string, content: string) => request>( `/agent/memory/${encodeURIComponent(date)}.md`, { method: "PUT", body: JSON.stringify({ content }), }, ), // System prompt files management getSystemPromptFiles: () => request("/agent/system-prompt-files"), setSystemPromptFiles: (files: string[]) => request("/agent/system-prompt-files", { method: "PUT", body: JSON.stringify(files), }), }; ================================================ FILE: console/src/api/request.ts ================================================ import { getApiUrl, getApiToken, clearAuthToken } from "./config"; function buildHeaders(method?: string, extra?: HeadersInit): Headers { // Normalize extra to a Headers instance for consistent handling const headers = extra instanceof Headers ? extra : new Headers(extra); // Only add Content-Type for methods that typically have a body if (method && ["POST", "PUT", "PATCH"].includes(method.toUpperCase())) { // Don't override if caller explicitly set Content-Type if (!headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } } // Add authorization token if available const token = getApiToken(); if (token) { headers.set("Authorization", `Bearer ${token}`); } // Add selected agent ID to all requests (for multi-agent support) try { const agentStorage = localStorage.getItem("copaw-agent-storage"); if (agentStorage) { const parsed = JSON.parse(agentStorage); const selectedAgent = parsed?.state?.selectedAgent; if (selectedAgent) { headers.set("X-Agent-Id", selectedAgent); } } } catch (error) { // Ignore localStorage errors console.warn("Failed to get selected agent from storage:", error); } return headers; } export async function request( path: string, options: RequestInit = {}, ): Promise { const url = getApiUrl(path); const method = options.method || "GET"; const headers = buildHeaders(method, options.headers); const response = await fetch(url, { ...options, headers, }); if (!response.ok) { // Handle 401: clear token and redirect to login if (response.status === 401) { clearAuthToken(); if (window.location.pathname !== "/login") { window.location.href = "/login"; } throw new Error("Not authenticated"); } const text = await response.text().catch(() => ""); throw new Error( `Request failed: ${response.status} ${response.statusText}${ text ? ` - ${text}` : "" }`, ); } if (response.status === 204) { return undefined as T; } const contentType = response.headers.get("content-type") || ""; if (!contentType.includes("application/json")) { return (await response.text()) as unknown as T; } return (await response.json()) as T; } ================================================ FILE: console/src/api/types/agent.ts ================================================ export interface AgentRequest { input: unknown; session_id?: string | null; user_id?: string | null; channel?: string | null; [key: string]: unknown; } export interface AgentsRunningConfig { max_iters: number; max_input_length: number; memory_compact_ratio: number; memory_reserve_ratio: number; tool_result_compact_recent_n: number; tool_result_compact_old_threshold: number; tool_result_compact_recent_threshold: number; tool_result_compact_retention_days: number; } ================================================ FILE: console/src/api/types/agents.ts ================================================ // Multi-agent management types export interface AgentSummary { id: string; name: string; description: string; workspace_dir: string; } export interface AgentListResponse { agents: AgentSummary[]; } export interface AgentProfileConfig { id: string; name: string; description?: string; workspace_dir?: string; channels?: unknown; mcp?: unknown; heartbeat?: unknown; running?: unknown; llm_routing?: unknown; system_prompt_files?: string[]; tools?: unknown; security?: unknown; } export interface CreateAgentRequest { name: string; description?: string; workspace_dir?: string; language?: string; } export interface AgentProfileRef { id: string; workspace_dir: string; } ================================================ FILE: console/src/api/types/channel.ts ================================================ export interface BaseChannelConfig { enabled: boolean; bot_prefix: string; filter_tool_messages?: boolean; filter_thinking?: boolean; dm_policy?: "open" | "allowlist"; group_policy?: "open" | "allowlist"; allow_from?: string[]; require_mention?: boolean; } export interface IMessageChannelConfig extends BaseChannelConfig { db_path: string; poll_sec: number; } export interface DiscordConfig extends BaseChannelConfig { bot_token: string; http_proxy: string; http_proxy_auth: string; } export interface DingTalkConfig extends BaseChannelConfig { client_id: string; client_secret: string; message_type: string; card_template_id: string; card_template_key: string; robot_code: string; } export interface FeishuConfig extends BaseChannelConfig { app_id: string; app_secret: string; encrypt_key: string; verification_token: string; media_dir: string; } export interface QQConfig extends BaseChannelConfig { app_id: string; client_secret: string; } export interface TelegramConfig extends BaseChannelConfig { bot_token: string; http_proxy: string; http_proxy_auth: string; show_typing?: boolean; } export interface MQTTConfig extends BaseChannelConfig { host: string; port: number; transport: string; clean_session: boolean; qos: number; username: string; password: string; subscribe_topic: string; publish_topic: string; tls_enabled?: boolean; tls_ca_certs?: string; tls_certfile?: string; tls_keyfile?: string; } export interface MatrixConfig extends BaseChannelConfig { homeserver: string; user_id: string; access_token: string; } export type ConsoleConfig = BaseChannelConfig; export interface VoiceChannelConfig extends BaseChannelConfig { twilio_account_sid: string; twilio_auth_token: string; phone_number: string; phone_number_sid: string; tts_provider: string; tts_voice: string; stt_provider: string; language: string; welcome_greeting: string; } export interface XiaoYiConfig extends BaseChannelConfig { ak: string; sk: string; agent_id: string; ws_url: string; task_timeout_ms?: number; } export interface ChannelConfig { imessage: IMessageChannelConfig; discord: DiscordConfig; dingtalk: DingTalkConfig; feishu: FeishuConfig; qq: QQConfig; telegram: TelegramConfig; mqtt: MQTTConfig; matrix: MatrixConfig; console: ConsoleConfig; voice: VoiceChannelConfig; xiaoyi: XiaoYiConfig; } export type SingleChannelConfig = | IMessageChannelConfig | DiscordConfig | DingTalkConfig | FeishuConfig | QQConfig | ConsoleConfig | TelegramConfig | MQTTConfig | MatrixConfig | VoiceChannelConfig | XiaoYiConfig; ================================================ FILE: console/src/api/types/chat.ts ================================================ export type ChatStatus = "idle" | "running"; export interface ChatSpec { id: string; // Chat UUID identifier session_id: string; // Session identifier (channel:user_id format) user_id: string; // User identifier channel: string; // Channel name, default: "default" created_at: string | null; // Chat creation timestamp (ISO 8601) updated_at: string | null; // Chat last update timestamp (ISO 8601) meta?: Record; // Additional metadata status?: ChatStatus; // Conversation status: idle or running } export interface Message { role: string; content: unknown; [key: string]: unknown; } export interface ChatHistory { messages: Message[]; status?: ChatStatus; // Conversation status: idle or running } export interface ChatDeleteResponse { success: boolean; chat_id: string; } // Legacy Session type alias for backward compatibility export type Session = ChatSpec; ================================================ FILE: console/src/api/types/cronjob.ts ================================================ export interface CronJobSchedule { type: "cron"; cron: string; timezone?: string; } export interface CronJobTarget { user_id: string; session_id: string; } export interface CronJobDispatch { type: "channel"; channel?: string; target: CronJobTarget; mode?: "stream" | "final"; meta?: Record; } export interface CronJobRuntime { max_concurrency?: number; timeout_seconds?: number; misfire_grace_seconds?: number; } export interface CronJobRequest { input: unknown; session_id?: string | null; user_id?: string | null; [key: string]: unknown; } export interface CronJobSpecInput { id: string; name: string; enabled?: boolean; schedule: CronJobSchedule; task_type?: "text" | "agent"; text?: string; request?: CronJobRequest; dispatch: CronJobDispatch; runtime?: CronJobRuntime; meta?: Record; } export type CronJobSpecOutput = CronJobSpecInput; export interface CronJobView extends CronJobSpecOutput { // Extended view with runtime state state?: unknown; next_run_time?: number; last_run_time?: number; } export type CronJobSpecInputLegacy = Record; export type CronJobSpecOutputLegacy = Record; export type CronJobViewLegacy = Record; ================================================ FILE: console/src/api/types/env.ts ================================================ export interface EnvVar { key: string; value: string; } ================================================ FILE: console/src/api/types/heartbeat.ts ================================================ export interface ActiveHoursConfig { start: string; end: string; } export interface HeartbeatConfig { enabled: boolean; every: string; target: string; activeHours?: ActiveHoursConfig | null; } ================================================ FILE: console/src/api/types/index.ts ================================================ export * from "./agent"; export * from "./agents"; export * from "./channel"; export * from "./heartbeat"; export * from "./chat"; export * from "./cronjob"; export * from "./env"; export * from "./mcp"; export * from "./provider"; export * from "./skill"; export * from "./workspace"; export * from "./tokenUsage"; ================================================ FILE: console/src/api/types/mcp.ts ================================================ /** * MCP (Model Context Protocol) client types */ export interface MCPClientInfo { /** Unique client key identifier */ key: string; /** Client display name */ name: string; /** Client description */ description: string; /** Whether the client is enabled */ enabled: boolean; /** MCP transport type */ transport: "stdio" | "streamable_http" | "sse"; /** Remote MCP endpoint URL for HTTP/SSE transport */ url: string; /** HTTP headers for remote transport */ headers: Record; /** Command to launch the MCP server */ command: string; /** Command-line arguments */ args: string[]; /** Environment variables */ env: Record; /** Working directory for stdio command */ cwd: string; } export interface MCPClientCreateRequest { /** Unique client key identifier */ client_key: string; /** Client configuration */ client: { /** Client display name */ name: string; /** Client description */ description?: string; /** Whether to enable the client */ enabled?: boolean; /** MCP transport type */ transport?: "stdio" | "streamable_http" | "sse"; /** Remote MCP endpoint URL for HTTP/SSE transport */ url?: string; /** HTTP headers for remote transport */ headers?: Record; /** Command to launch the MCP server */ command?: string; /** Command-line arguments */ args?: string[]; /** Environment variables */ env?: Record; /** Working directory for stdio command */ cwd?: string; }; } export interface MCPClientUpdateRequest { /** Client display name */ name?: string; /** Client description */ description?: string; /** Whether to enable the client */ enabled?: boolean; /** MCP transport type */ transport?: "stdio" | "streamable_http" | "sse"; /** Remote MCP endpoint URL for HTTP/SSE transport */ url?: string; /** HTTP headers for remote transport */ headers?: Record; /** Command to launch the MCP server */ command?: string; /** Command-line arguments */ args?: string[]; /** Environment variables */ env?: Record; /** Working directory for stdio command */ cwd?: string; } ================================================ FILE: console/src/api/types/provider.ts ================================================ export interface ModelInfo { id: string; name: string; } export interface ProviderInfo { id: string; name: string; api_key_prefix: string; chat_model: string; /** Built-in models (for built-in providers) or all models (for custom). */ models: ModelInfo[]; /** User-added models (deletable). Only populated for built-in providers. */ extra_models: ModelInfo[]; is_custom: boolean; is_local: boolean; /** Whether this provider supports fetching available models from the provider's API. */ support_model_discovery: boolean; /** Whether this provider supports checking connection to the API without model configuration. */ support_connection_check: boolean; /** True when the base_url should be frozen (not editable). */ freeze_url: boolean; /** True when an API key is required for this provider. */ require_api_key: boolean; api_key: string; base_url: string; generate_kwargs: Record; } export interface ProviderConfigRequest { api_key?: string; base_url?: string; chat_model?: string; generate_kwargs?: Record; } export interface ModelSlotConfig { provider_id: string; model: string; } export interface ActiveModelsInfo { active_llm?: ModelSlotConfig; } export interface ModelSlotRequest { provider_id: string; model: string; } /* ---- Custom provider CRUD ---- */ export interface CreateCustomProviderRequest { id: string; name: string; default_base_url?: string; api_key_prefix?: string; chat_model?: string; models?: ModelInfo[]; } export interface AddModelRequest { id: string; name: string; } /* ---- Local models ---- */ export interface LocalModelResponse { id: string; repo_id: string; filename: string; backend: string; source: string; file_size: number; local_path: string; display_name: string; } export interface DownloadModelRequest { repo_id: string; filename?: string; backend: string; source: string; } export interface DownloadTaskResponse { task_id: string; status: "pending" | "downloading" | "completed" | "failed" | "cancelled"; repo_id: string; filename: string | null; backend: string; source: string; error: string | null; result: LocalModelResponse | null; } /* ---- Ollama models ---- */ export interface OllamaModelResponse { name: string; size: number; digest?: string | null; modified_at?: string | null; } export interface OllamaDownloadRequest { name: string; } export interface OllamaDownloadTaskResponse { task_id: string; status: "pending" | "downloading" | "completed" | "failed" | "cancelled"; name: string; error: string | null; result: OllamaModelResponse | null; } /* ---- Test Connection ---- */ export interface TestConnectionResponse { success: boolean; message: string; } export interface TestProviderRequest { api_key?: string; base_url?: string; chat_model?: string; generate_kwargs?: Record; } export interface TestModelRequest { model_id: string; } export interface DiscoverModelsResponse { success: boolean; message: string; models: ModelInfo[]; added_count: number; } ================================================ FILE: console/src/api/types/skill.ts ================================================ export interface SkillSpec { name: string; description?: string; content: string; source: string; path: string; enabled?: boolean; } export interface HubSkillSpec { slug: string; name: string; description: string; version: string; source_url: string; } // Legacy Skill interface for backward compatibility export interface Skill { id: string; name: string; description: string; function_name: string; enabled: boolean; version: string; tags: string[]; created_at: number; updated_at: number; } ================================================ FILE: console/src/api/types/tokenUsage.ts ================================================ /** Per-model (has provider_id, model) or per-date (no provider_id, model) stats. */ export interface TokenUsageStats { provider_id?: string; model?: string; prompt_tokens: number; completion_tokens: number; call_count: number; } export interface TokenUsageSummary { total_prompt_tokens: number; total_completion_tokens: number; total_calls: number; by_model: Record; by_date: Record; } ================================================ FILE: console/src/api/types/workspace.ts ================================================ export interface MdFileInfo { filename: string; path: string; size: number; created_time: string; modified_time: string; } export interface MdFileContent { content: string; } export interface MarkdownFile extends MdFileInfo { updated_at: number; enabled?: boolean; } export interface DailyMemoryFile extends MdFileInfo { date: string; updated_at: number; } ================================================ FILE: console/src/components/AgentSelector/index.module.less ================================================ // ─── Wrapper ────────────────────────────────────────────────────────────────── .agentSelectorWrapper { display: flex; align-items: center; gap: 8px; padding: 0; background: transparent; border: none; transition: all 0.2s ease; } // ─── Label ──────────────────────────────────────────────────────────────────── .agentSelectorLabel { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 500; color: rgba(0, 0, 0, 0.4); white-space: nowrap; user-select: none; svg { opacity: 0.5; } } // ─── Select Component ───────────────────────────────────────────────────────── .agentSelector { min-width: 180px; :global { .ant-select-selector { border: 1px solid rgba(97, 92, 237, 0.25) !important; background: rgba(97, 92, 237, 0.04) !important; box-shadow: none !important; padding: 4px 8px !important; height: 32px !important; font-size: 13px; font-weight: 500; border-radius: 8px !important; transition: all 0.2s ease; &:hover { border-color: rgba(97, 92, 237, 0.45) !important; background: rgba(97, 92, 237, 0.07) !important; } &:focus { border-color: #615ced !important; background: #ffffff !important; box-shadow: 0 0 0 2px rgba(97, 92, 237, 0.12) !important; } } .ant-select-selection-item { padding: 0 !important; line-height: 22px; } .ant-select-arrow { color: rgba(97, 92, 237, 0.5); right: 8px; .ant-select-suffix { display: flex; align-items: center; } } .ant-select-focused { .ant-select-selector { border-color: #615ced !important; background: #ffffff !important; box-shadow: 0 0 0 2px rgba(97, 92, 237, 0.12) !important; } } } } // ─── Selected Agent Label ───────────────────────────────────────────────────── .selectedAgentLabel { display: flex; align-items: center; gap: 6px; color: rgba(0, 0, 0, 0.85); svg { flex-shrink: 0; color: #615ced; opacity: 0.85; } span { font-weight: 600; font-size: 13px; } } // ─── Agent Badge ────────────────────────────────────────────────────────────── .agentSelectorSuffix { display: flex; align-items: center; gap: 6px; margin-right: 0; .agentBadge { :global { .ant-badge-count { background: linear-gradient(135deg, #615ced, #8b87f0); color: #ffffff; font-size: 10px; font-weight: 700; min-width: 18px; height: 18px; line-height: 18px; border-radius: 9px; box-shadow: 0 2px 6px rgba(97, 92, 237, 0.35); } } } } // ─── Dropdown ───────────────────────────────────────────────────────────────── .agentSelectorDropdown { border-radius: 12px !important; overflow: hidden; min-width: 280px !important; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.06) !important; :global { .copaw-select-item { padding: 0 !important; border-radius: 8px; margin: 3px 6px; transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); &:hover { background: rgba(97, 92, 237, 0.08) !important; } &.copaw-select-item-option-selected { background: rgba(97, 92, 237, 0.05) !important; .agentOptionIcon { background: linear-gradient(135deg, #615ced 0%, #8b87f0 100%); border-color: transparent; color: #ffffff; box-shadow: 0 2px 8px rgba(97, 92, 237, 0.4); } } } .ant-select-item { padding: 0 !important; border-radius: 8px; margin: 3px 6px; transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); &:hover { background: rgba(97, 92, 237, 0.08) !important; } &.ant-select-item-option-selected { background: rgba(97, 92, 237, 0.05) !important; .agentOptionIcon { background: linear-gradient(135deg, #615ced 0%, #8b87f0 100%); border-color: transparent; color: #ffffff; box-shadow: 0 2px 8px rgba(97, 92, 237, 0.4); } } } .rc-virtual-list { padding: 6px 0; } } } // ─── Agent Option ───────────────────────────────────────────────────────────── .agentOption { padding: 10px 14px; display: flex; flex-direction: column; gap: 6px; } .agentOptionHeader { display: flex; align-items: center; gap: 10px; } .agentOptionIcon { flex-shrink: 0; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #f5f5ff 0%, #efefff 100%); border: 1px solid rgba(97, 92, 237, 0.15); border-radius: 8px; color: #615ced; transition: all 0.2s ease; .agentOption:hover & { background: linear-gradient(135deg, #ebe9ff 0%, #f0efff 100%); border-color: rgba(97, 92, 237, 0.3); color: #615ced; box-shadow: 0 2px 6px rgba(97, 92, 237, 0.15); } } .agentOptionContent { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } .agentOptionName { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; color: #1a1a2e; line-height: 1.4; span { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } } .activeIndicator { flex-shrink: 0; color: #52c41a; animation: fadeIn 0.3s ease; } @keyframes fadeIn { from { opacity: 0; transform: scale(0.8); } to { opacity: 1; transform: scale(1); } } .agentOptionDescription { font-size: 11px; color: #8c8c8c; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .agentOptionId { font-size: 10px; font-family: "SF Mono", "Consolas", "Monaco", monospace; color: rgba(97, 92, 237, 0.4); letter-spacing: 0.3px; padding-top: 4px; border-top: 1px solid rgba(97, 92, 237, 0.06); } // ─── Dark mode overrides ─────────────────────────────────────────────────────── :global(.dark-mode) { .agentSelectorLabel { color: rgba(255, 255, 255, 0.55); svg { opacity: 0.7; } } .agentSelector { :global { .ant-select-selector { border-color: rgba(139, 135, 240, 0.3) !important; background: rgba(97, 92, 237, 0.1) !important; &:hover { border-color: rgba(139, 135, 240, 0.5) !important; background: rgba(97, 92, 237, 0.15) !important; } &:focus { border-color: #8b87f0 !important; background: rgba(97, 92, 237, 0.15) !important; box-shadow: 0 0 0 2px rgba(97, 92, 237, 0.25) !important; } } .ant-select-arrow { color: rgba(139, 135, 240, 0.6); } .ant-select-focused .ant-select-selector { border-color: #8b87f0 !important; background: rgba(97, 92, 237, 0.15) !important; box-shadow: 0 0 0 2px rgba(97, 92, 237, 0.25) !important; } } } .selectedAgentLabel { color: rgba(255, 255, 255, 0.9); svg { color: #b8b5f5; opacity: 0.9; } } .agentSelectorSuffix { .agentBadge { :global { .ant-badge-count { background: linear-gradient(135deg, #615ced, #8b87f0); color: #ffffff; box-shadow: 0 2px 6px rgba(97, 92, 237, 0.4); } } } } .agentSelectorDropdown { box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.2) !important; :global { .ant-select-item { &:hover { background: rgba(255, 255, 255, 0.1) !important; } &.ant-select-item-option-selected { background: rgba(255, 255, 255, 0.06) !important; .agentOptionIcon { background: linear-gradient(135deg, #615ced 0%, #8b87f0 100%); border-color: transparent; color: #ffffff; box-shadow: 0 2px 8px rgba(97, 92, 237, 0.5); } } } } } .agentOptionIcon { background: linear-gradient( 135deg, rgba(97, 92, 237, 0.15) 0%, rgba(97, 92, 237, 0.08) 100% ); border-color: rgba(139, 135, 240, 0.2); color: #b8b5f5; .agentOption:hover & { background: linear-gradient( 135deg, rgba(97, 92, 237, 0.3) 0%, rgba(97, 92, 237, 0.15) 100% ); border-color: rgba(139, 135, 240, 0.4); color: #c5c3f8; box-shadow: 0 2px 8px rgba(97, 92, 237, 0.3); } } .agentOptionName { color: rgba(255, 255, 255, 0.88); } .agentOptionDescription { color: rgba(255, 255, 255, 0.4); } .agentOptionId { color: rgba(139, 135, 240, 0.45); border-top-color: rgba(139, 135, 240, 0.1); } } ================================================ FILE: console/src/components/AgentSelector/index.tsx ================================================ import { Select, message, Badge } from "antd"; import { useEffect, useState } from "react"; import { Bot, Layers, CheckCircle } from "lucide-react"; import { useAgentStore } from "../../stores/agentStore"; import { agentsApi } from "../../api/modules/agents"; import { useTranslation } from "react-i18next"; import styles from "./index.module.less"; export default function AgentSelector() { const { t } = useTranslation(); const { selectedAgent, agents, setSelectedAgent, setAgents } = useAgentStore(); const [loading, setLoading] = useState(false); useEffect(() => { loadAgents(); }, []); const loadAgents = async () => { try { setLoading(true); const data = await agentsApi.listAgents(); setAgents(data.agents); } catch (error) { console.error("Failed to load agents:", error); message.error(t("agent.loadFailed")); } finally { setLoading(false); } }; const handleChange = (value: string) => { setSelectedAgent(value); message.success(t("agent.switchSuccess")); }; const agentCount = agents.length; return (
{t("agent.currentWorkspace")}
); } ================================================ FILE: console/src/components/ConsoleCronBubble/index.module.less ================================================ .wrap { position: fixed; top: 24px; right: 24px; z-index: 1000; display: flex; flex-direction: column; gap: 12px; max-width: min(400px, calc(100vw - 48px)); pointer-events: none; & > * { pointer-events: auto; } } .bubble { display: flex; align-items: flex-start; gap: 12px; padding: 14px 16px; background: var(--colorBgElevated, #fff); border-radius: 12px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); border: 1px solid var(--colorBorderSecondary, #f0f0f0); } .icon { flex-shrink: 0; color: var(--colorPrimary, #1677ff); margin-top: 2px; } .text { flex: 1; margin: 0; font-size: 14px; line-height: 1.5; color: var(--colorText, #333); word-break: break-word; white-space: pre-wrap; max-height: 6em; overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 4; line-clamp: 4; } .close { flex-shrink: 0; padding: 4px; border: none; background: transparent; color: var(--colorTextSecondary, #999); cursor: pointer; border-radius: 6px; display: flex; align-items: center; justify-content: center; &:hover { color: var(--colorText, #333); background: var(--colorFillTertiary, #f5f5f5); } } ================================================ FILE: console/src/components/ConsoleCronBubble/index.tsx ================================================ import { useEffect, useRef, useState } from "react"; import { MessageCircle, X } from "lucide-react"; import { consoleApi, type PushMessage } from "../../api/modules/console"; import styles from "./index.module.less"; const POLL_INTERVAL_MS = 2500; const AUTO_DISMISS_MS = 8000; const MAX_SEEN_IDS = 500; const MAX_VISIBLE_BUBBLES = 4; const MAX_NEW_PER_POLL = 2; const TITLE_BLINK_PREFIX = "\u2022 "; interface BubbleItem extends PushMessage { dismissAt: number; } export default function ConsoleCronBubble() { const [items, setItems] = useState([]); const pollRef = useRef | null>(null); const seenIdsRef = useRef>(new Set()); const originalTitleRef = useRef(document.title); const blinkRef = useRef | null>(null); const dismiss = (id: string) => { setItems((prev) => prev.filter((i) => i.id !== id)); }; useEffect(() => { originalTitleRef.current = document.title; }, []); useEffect(() => { const tick = () => { consoleApi .getPushMessages() .then((res) => { if (!res?.messages?.length) return; const seen = seenIdsRef.current; if (seen.size > MAX_SEEN_IDS) seen.clear(); const newItems: BubbleItem[] = []; const now = Date.now(); for (const m of res.messages) { if (seen.has(m.id)) continue; seen.add(m.id); newItems.push({ ...m, dismissAt: now + AUTO_DISMISS_MS }); } if (newItems.length === 0) return; const toAdd = newItems.slice(-MAX_NEW_PER_POLL); setItems((prev) => { const merged = [...prev, ...toAdd]; return merged.slice(-MAX_VISIBLE_BUBBLES); }); }) .catch(() => {}); }; tick(); pollRef.current = setInterval(tick, POLL_INTERVAL_MS); return () => { if (pollRef.current) clearInterval(pollRef.current); }; }, []); useEffect(() => { if (items.length === 0) return; const t = setInterval(() => { const now = Date.now(); setItems((prev) => { const next = prev.filter((i) => i.dismissAt > now); return next.length === prev.length ? prev : next; }); }, 500); return () => clearInterval(t); }, [items.length]); useEffect(() => { if (items.length === 0 || !document.hidden || blinkRef.current) return; const original = originalTitleRef.current; let showPrefix = true; blinkRef.current = setInterval(() => { document.title = showPrefix ? `${TITLE_BLINK_PREFIX}${original}` : original; showPrefix = !showPrefix; }, 800); return () => { if (blinkRef.current) { clearInterval(blinkRef.current); blinkRef.current = null; } document.title = original; }; }, [items.length]); useEffect(() => { const onVisibility = () => { if (document.visibilityState === "visible") { if (blinkRef.current) { clearInterval(blinkRef.current); blinkRef.current = null; } document.title = originalTitleRef.current; } }; document.addEventListener("visibilitychange", onVisibility); return () => document.removeEventListener("visibilitychange", onVisibility); }, []); if (items.length === 0) return null; return (
{items.map((item) => (

{item.text}

))}
); } ================================================ FILE: console/src/components/LanguageSwitcher.tsx ================================================ import { Dropdown, Button } from "@agentscope-ai/design"; import { GlobalOutlined } from "@ant-design/icons"; import { useTranslation } from "react-i18next"; import type { MenuProps } from "antd"; export default function LanguageSwitcher() { const { i18n } = useTranslation(); const currentLanguage = i18n.resolvedLanguage || i18n.language; const changeLanguage = (lang: string) => { i18n.changeLanguage(lang); localStorage.setItem("language", lang); }; const items: MenuProps["items"] = [ { key: "en", label: "English", onClick: () => changeLanguage("en"), }, { key: "ru", label: "Русский", onClick: () => changeLanguage("ru"), }, { key: "zh", label: "简体中文", onClick: () => changeLanguage("zh"), }, { key: "ja", label: "日本語", onClick: () => changeLanguage("ja"), }, ]; const languageLabels: Record = { en: "English", ru: "Русский", zh: "简体中文", ja: "日本語", }; const currentLabel = languageLabels[currentLanguage] ?? "English"; return ( ); } ================================================ FILE: console/src/components/MarkdownCopy/MarkdownCopy.tsx ================================================ import { useState, useEffect, useMemo } from "react"; import { Button, message, Switch, Input } from "@agentscope-ai/design"; import { CopyOutlined } from "@ant-design/icons"; import { XMarkdown } from "@ant-design/x-markdown"; import { useTranslation } from "react-i18next"; import type { CSSProperties } from "react"; import { stripFrontmatter } from "../../utils/markdown"; import styles from "./index.module.less"; interface MarkdownCopyProps { content: string; showMarkdown?: boolean; onShowMarkdownChange?: (show: boolean) => void; copyButtonProps?: { type?: | "text" | "link" | "default" | "primary" | "dashed" | "primaryLess" | "textCompact" | undefined; size?: "small" | "middle" | "large" | undefined; style?: CSSProperties; }; markdownViewerProps?: { style?: CSSProperties; className?: string; }; textareaProps?: { rows?: number; placeholder?: string; disabled?: boolean; style?: CSSProperties; className?: string; }; showControls?: boolean; editable?: boolean; onContentChange?: (content: string) => void; } export function MarkdownCopy({ content, showMarkdown = true, onShowMarkdownChange, copyButtonProps = {}, markdownViewerProps = {}, textareaProps = {}, showControls = true, editable = false, onContentChange, }: MarkdownCopyProps) { const { t } = useTranslation(); const [isCopying, setIsCopying] = useState(false); const [editContent, setEditContent] = useState(content); const [localShowMarkdown, setLocalShowMarkdown] = useState(showMarkdown); const markdownContent = useMemo( () => stripFrontmatter(content || ""), [content], ); useEffect(() => { setEditContent(content); }, [content]); useEffect(() => { if (editable && !textareaProps.disabled) { setLocalShowMarkdown(false); } else { setLocalShowMarkdown(showMarkdown); } }, [editable, textareaProps.disabled, showMarkdown]); const copyToClipboard = async () => { const contentToCopy = localShowMarkdown && !(editable && !textareaProps.disabled) ? content : editable ? editContent : content; if (!contentToCopy) return; setIsCopying(true); try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(contentToCopy); message.success(t("common.copied")); } else { const textArea = document.createElement("textarea"); textArea.value = contentToCopy; textArea.style.position = "fixed"; textArea.style.left = "-999999px"; textArea.style.top = "-999999px"; document.body.appendChild(textArea); textArea.focus(); textArea.select(); document.execCommand("copy"); textArea.remove(); message.success(t("common.copied")); } } catch (err) { console.error("Failed to copy text: ", err); message.error(t("common.copyFailed")); } finally { setIsCopying(false); } }; const handleContentChange = (e: React.ChangeEvent) => { const newContent = e.target.value; setEditContent(newContent); if (onContentChange) { onContentChange(newContent); } }; const handleShowMarkdownChange = (show: boolean) => { setLocalShowMarkdown(show); if (onShowMarkdownChange) { onShowMarkdownChange(show); } }; const defaultCopyButtonProps = { type: "text" as const, size: "small" as const, ...copyButtonProps, }; const defaultMarkdownViewerProps = { style: { padding: 16, height: "100%", overflow: "auto", backgroundColor: "#fff", borderRadius: 6, ...markdownViewerProps.style, }, ...markdownViewerProps, }; const defaultTextareaProps = { rows: 12, placeholder: t("common.contentPlaceholder"), ...textareaProps, }; return (
{showControls && (
{t("common.content")}
{t("common.preview")}
)} {localShowMarkdown ? (
) : (
)}
); } ================================================ FILE: console/src/components/MarkdownCopy/index.module.less ================================================ .markdownCopy { display: flex; flex-direction: column; height: 100%; } .controls { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 12px; font-weight: 600; height: 20px; } .controlGroup { display: flex; align-items: center; gap: 8px; } .previewToggle { display: flex; align-items: center; gap: 8px; } .previewLabel { font-size: 12px; color: #666; white-space: nowrap; } .markdownViewer { flex: 1; min-height: 300px; max-height: 300px; overflow: auto; border: 1px solid #d9d9d9; border-radius: 6px; background-color: #fff; } .textareaContainer { flex: 1; display: flex; flex-direction: column; } .textarea { font-family: monospace; font-size: 13px; resize: vertical; height: min(100%, clamp(280px, 50vh, 640px)); min-height: min(240px, 100%); max-height: 100%; padding: 12px; border: 1px solid #d9d9d9; border-radius: 6px; } ================================================ FILE: console/src/components/PageHeader/index.tsx ================================================ ================================================ FILE: console/src/components/ThemeToggleButton/index.module.less ================================================ /* ---- Theme toggle button ---- */ .toggleBtn { display: inline-flex; align-items: center; justify-content: center; height: 32px; border: none; border-radius: 8px; background: transparent; cursor: pointer; transition: background 0.15s ease, color 0.15s ease; color: #555; padding: 0; flex-shrink: 0; &:hover { background: rgba(97, 92, 237, 0.08); color: #615ced; } } .icon { font-size: 16px; } /* Dark mode overrides */ :global(.dark-mode) .toggleBtn { color: #ccc; &:hover { background: rgba(255, 255, 255, 0.1); color: #fff; } } ================================================ FILE: console/src/components/ThemeToggleButton/index.tsx ================================================ import { Tooltip, Button } from "antd"; import { SunOutlined, MoonOutlined } from "@ant-design/icons"; import { useTheme } from "../../contexts/ThemeContext"; import { useTranslation } from "react-i18next"; import styles from "./index.module.less"; /** * ThemeToggleButton - toggles between light and dark theme. * Displays a sun icon in dark mode and a moon icon in light mode. */ export default function ThemeToggleButton() { const { isDark, toggleTheme } = useTheme(); const { t } = useTranslation(); return ( ); } ================================================ FILE: console/src/constants/timezone.ts ================================================ export const TIMEZONE_OPTIONS = [ { value: "America/Los_Angeles", label: "America/Los_Angeles (UTC-8)" }, { value: "America/Denver", label: "America/Denver (UTC-7)" }, { value: "America/Chicago", label: "America/Chicago (UTC-6)" }, { value: "America/New_York", label: "America/New_York (UTC-5)" }, { value: "America/Toronto", label: "America/Toronto (UTC-5)" }, { value: "UTC", label: "UTC" }, { value: "Europe/London", label: "Europe/London (UTC+0)" }, { value: "Europe/Paris", label: "Europe/Paris (UTC+1)" }, { value: "Europe/Berlin", label: "Europe/Berlin (UTC+1)" }, { value: "Europe/Moscow", label: "Europe/Moscow (UTC+3)" }, { value: "Asia/Dubai", label: "Asia/Dubai (UTC+4)" }, { value: "Asia/Shanghai", label: "Asia/Shanghai (UTC+8)" }, { value: "Asia/Hong_Kong", label: "Asia/Hong_Kong (UTC+8)" }, { value: "Asia/Singapore", label: "Asia/Singapore (UTC+8)" }, { value: "Asia/Tokyo", label: "Asia/Tokyo (UTC+9)" }, { value: "Asia/Seoul", label: "Asia/Seoul (UTC+9)" }, { value: "Australia/Sydney", label: "Australia/Sydney (UTC+10)" }, { value: "Australia/Melbourne", label: "Australia/Melbourne (UTC+10)" }, { value: "Pacific/Auckland", label: "Pacific/Auckland (UTC+12)" }, ]; ================================================ FILE: console/src/contexts/ThemeContext.tsx ================================================ import { createContext, useContext, useEffect, useState, useCallback, type ReactNode, } from "react"; export type ThemeMode = "light" | "dark" | "system"; export type ResolvedTheme = "light" | "dark"; const STORAGE_KEY = "copaw-theme"; interface ThemeContextValue { /** User selected preference: light / dark / system */ themeMode: ThemeMode; /** Resolved final theme after applying system preference */ isDark: boolean; setThemeMode: (mode: ThemeMode) => void; /** Convenience toggle: light ↔ dark (skips system) */ toggleTheme: () => void; } const ThemeContext = createContext({ themeMode: "light", isDark: false, setThemeMode: () => {}, toggleTheme: () => {}, }); function getInitialMode(): ThemeMode { try { const stored = localStorage.getItem(STORAGE_KEY); if (stored === "light" || stored === "dark" || stored === "system") { return stored; } } catch { // ignore storage errors } return "system"; } function resolveIsDark(mode: ThemeMode): boolean { if (mode === "dark") return true; if (mode === "light") return false; // system return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false; } export function ThemeProvider({ children }: { children: ReactNode }) { const [themeMode, setThemeModeState] = useState(getInitialMode); const [isDark, setIsDark] = useState(() => resolveIsDark(getInitialMode()), ); // Apply dark/light class to element for global CSS variable overrides useEffect(() => { const html = document.documentElement; if (isDark) { html.classList.add("dark-mode"); } else { html.classList.remove("dark-mode"); } }, [isDark]); // Listen to system theme changes when mode is "system" useEffect(() => { if (themeMode !== "system") return; const mq = window.matchMedia("(prefers-color-scheme: dark)"); const handler = (e: MediaQueryListEvent) => { setIsDark(e.matches); }; mq.addEventListener("change", handler); return () => mq.removeEventListener("change", handler); }, [themeMode]); const setThemeMode = useCallback((mode: ThemeMode) => { setThemeModeState(mode); setIsDark(resolveIsDark(mode)); try { localStorage.setItem(STORAGE_KEY, mode); } catch { // ignore } }, []); const toggleTheme = useCallback(() => { setThemeMode(isDark ? "light" : "dark"); }, [isDark, setThemeMode]); return ( {children} ); } export function useTheme(): ThemeContextValue { return useContext(ThemeContext); } ================================================ FILE: console/src/i18n.ts ================================================ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; import en from "./locales/en.json"; import ru from "./locales/ru.json"; import zh from "./locales/zh.json"; import ja from "./locales/ja.json"; const resources = { en: { translation: en, }, ru: { translation: ru, }, zh: { translation: zh, }, ja: { translation: ja, }, }; i18n.use(initReactI18next).init({ resources, lng: localStorage.getItem("language") || "en", fallbackLng: "en", interpolation: { escapeValue: false, }, }); export default i18n; ================================================ FILE: console/src/layouts/Header.tsx ================================================ import { Layout, Space } from "antd"; import LanguageSwitcher from "../components/LanguageSwitcher"; import ThemeToggleButton from "../components/ThemeToggleButton"; import AgentSelector from "../components/AgentSelector"; import { useTranslation } from "react-i18next"; import { FileTextOutlined, BookOutlined, QuestionCircleOutlined, GithubOutlined, } from "@ant-design/icons"; import { Button, Tooltip } from "@agentscope-ai/design"; import styles from "./index.module.less"; import { GITHUB_URL, KEY_TO_LABEL, getDocsUrl, getFaqUrl, getReleaseNotesUrl, } from "./constants"; const { Header: AntHeader } = Layout; interface HeaderProps { selectedKey: string; } export default function Header({ selectedKey }: HeaderProps) { const { t, i18n } = useTranslation(); const handleNavClick = (url: string) => { if (url) { const pywebview = (window as any).pywebview; if (pywebview?.api) { pywebview.api.open_external_link(url); } else { window.open(url, "_blank"); } } }; return ( {t(KEY_TO_LABEL[selectedKey] || "nav.chat")} ); } ================================================ FILE: console/src/layouts/MainLayout/index.tsx ================================================ import { Layout } from "antd"; import { Routes, Route, useLocation, Navigate } from "react-router-dom"; import Sidebar from "../Sidebar"; import Header from "../Header"; import ConsoleCronBubble from "../../components/ConsoleCronBubble"; import styles from "../index.module.less"; import Chat from "../../pages/Chat"; import ChannelsPage from "../../pages/Control/Channels"; import SessionsPage from "../../pages/Control/Sessions"; import CronJobsPage from "../../pages/Control/CronJobs"; import HeartbeatPage from "../../pages/Control/Heartbeat"; import AgentConfigPage from "../../pages/Agent/Config"; import SkillsPage from "../../pages/Agent/Skills"; import ToolsPage from "../../pages/Agent/Tools"; import WorkspacePage from "../../pages/Agent/Workspace"; import MCPPage from "../../pages/Agent/MCP"; import ModelsPage from "../../pages/Settings/Models"; import EnvironmentsPage from "../../pages/Settings/Environments"; import SecurityPage from "../../pages/Settings/Security"; import TokenUsagePage from "../../pages/Settings/TokenUsage"; import VoiceTranscriptionPage from "../../pages/Settings/VoiceTranscription"; import AgentsPage from "../../pages/Settings/Agents"; const { Content } = Layout; const pathToKey: Record = { "/chat": "chat", "/channels": "channels", "/sessions": "sessions", "/cron-jobs": "cron-jobs", "/heartbeat": "heartbeat", "/skills": "skills", "/tools": "tools", "/mcp": "mcp", "/workspace": "workspace", "/agents": "agents", "/models": "models", "/environments": "environments", "/agent-config": "agent-config", "/security": "security", "/token-usage": "token-usage", "/voice-transcription": "voice-transcription", }; export default function MainLayout() { const location = useLocation(); const currentPath = location.pathname; const selectedKey = pathToKey[currentPath] || "chat"; return (
} /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } />
); } ================================================ FILE: console/src/layouts/Sidebar.tsx ================================================ import { Layout, Menu, Button, Badge, Modal, Spin, Tooltip, type MenuProps, } from "antd"; import { useState, useEffect, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { MessageSquare, Radio, Zap, MessageCircle, Wifi, UsersRound, CalendarClock, Activity, Sparkles, Briefcase, Cpu, Box, Globe, Settings, Shield, Plug, Wrench, PanelLeftClose, PanelLeftOpen, Copy, Check, BarChart3, Mic, Bot, LogOut, } from "lucide-react"; import api from "../api"; import { clearAuthToken } from "../api/config"; import { authApi } from "../api/modules/auth"; import styles from "./index.module.less"; import { useTheme } from "../contexts/ThemeContext"; import { PYPI_URL, ONE_HOUR_MS, DEFAULT_OPEN_KEYS, KEY_TO_PATH, UPDATE_MD, isStableVersion, compareVersions, } from "./constants"; // ── Layout ──────────────────────────────────────────────────────────────── const { Sider } = Layout; // ── Types ───────────────────────────────────────────────────────────────── interface SidebarProps { selectedKey: string; } // ── CopyButton ──────────────────────────────────────────────────────────── function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); const { t } = useTranslation(); const handleCopy = useCallback(() => { navigator.clipboard.writeText(text).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); }, [text]); return ( )} setUpdateModalOpen(false)} title={

{t("sidebar.updateModal.title", { version: latestVersion })}

} width={680} footer={[ , , ]} >
{!updateMarkdown ? (
) : ( {children} ); } return ( {children} ); }, }} > {updateMarkdown} )}
); } ================================================ FILE: console/src/layouts/constants.ts ================================================ // ── URLs ────────────────────────────────────────────────────────────────── export const PYPI_URL = "https://pypi.org/pypi/copaw/json"; export const GITHUB_URL = "https://github.com/agentscope-ai/CoPaw" as const; // ── Timing ──────────────────────────────────────────────────────────────── export const ONE_HOUR_MS = 60 * 60 * 1000; // ── Navigation ──────────────────────────────────────────────────────────── export const DEFAULT_OPEN_KEYS = [ "chat-group", "control-group", "agent-group", "settings-group", ]; export const KEY_TO_PATH: Record = { chat: "/chat", channels: "/channels", sessions: "/sessions", "cron-jobs": "/cron-jobs", heartbeat: "/heartbeat", skills: "/skills", tools: "/tools", mcp: "/mcp", workspace: "/workspace", agents: "/agents", models: "/models", environments: "/environments", "agent-config": "/agent-config", security: "/security", "token-usage": "/token-usage", "voice-transcription": "/voice-transcription", }; export const KEY_TO_LABEL: Record = { chat: "nav.chat", channels: "nav.channels", sessions: "nav.sessions", "cron-jobs": "nav.cronJobs", heartbeat: "nav.heartbeat", skills: "nav.skills", tools: "nav.tools", mcp: "nav.mcp", "agent-config": "nav.agentConfig", workspace: "nav.workspace", models: "nav.models", environments: "nav.environments", security: "nav.security", "token-usage": "nav.tokenUsage", agents: "nav.agents", }; // ── URL helpers ─────────────────────────────────────────────────────────── export const getWebsiteLang = (lang: string): string => lang.startsWith("zh") ? "zh" : "en"; export const getDocsUrl = (lang: string): string => `https://copaw.agentscope.io/docs/intro?lang=${getWebsiteLang(lang)}`; export const getFaqUrl = (lang: string): string => `https://copaw.agentscope.io/docs/faq?lang=${getWebsiteLang(lang)}`; export const getReleaseNotesUrl = (lang: string): string => `https://copaw.agentscope.io/release-notes?lang=${getWebsiteLang(lang)}`; // ── Version helpers ──────────────────────────────────────────────────────── // Filter out pre-release versions; post-releases are treated as stable. // PEP 440 pre-release suffixes: aN / bN / rcN (or cN) / devN. export const isStableVersion = (v: string): boolean => !/(\d)(a|alpha|b|beta|rc|c|dev)\d*/i.test(v); // Compare two PEP 440 version strings. Returns >0 if a>b, <0 if a 1.0.0). export const compareVersions = (a: string, b: string): number => { const normalise = (v: string) => v .replace(/\.post(\d+)/i, ".$1") .split(/[.\-]/) .map((seg) => (isNaN(Number(seg)) ? 0 : Number(seg))); const aN = normalise(a); const bN = normalise(b); const len = Math.max(aN.length, bN.length); for (let i = 0; i < len; i++) { const diff = (aN[i] ?? 0) - (bN[i] ?? 0); if (diff !== 0) return diff; } return 0; }; // ── Update markdown ─────────────────────────────────────────────────────── export const UPDATE_MD: Record = { zh: `### CoPaw如何更新 要更新 CoPaw 到最新版本,可根据你的安装方式选择对应方法: 1. 如果你使用的是一键安装脚本,直接重新运行安装命令即可自动升级。 2. 如果你是通过 pip 安装,在终端中执行以下命令升级: \`\`\` pip install --upgrade copaw \`\`\` 3. 如果你是从源码安装,进入项目目录并拉取最新代码后重新安装: \`\`\` cd CoPaw git pull origin main pip install -e . \`\`\` 4. 如果你使用的是 Docker,拉取最新镜像并重启容器: \`\`\` docker pull agentscope/copaw:latest docker run -p 127.0.0.1:8088:8088 -v copaw-data:/app/working agentscope/copaw:latest \`\`\` 升级后重启服务 copaw app。`, ru: `### Как обновить CoPaw Чтобы обновить CoPaw, выберите способ в зависимости от типа установки: 1. Если вы устанавливали через однострочный скрипт, повторно запустите установщик для обновления. 2. Если устанавливали через pip, выполните: \`\`\` pip install --upgrade copaw \`\`\` 3. Если устанавливали из исходников, получите последние изменения и переустановите: \`\`\` cd CoPaw git pull origin main pip install -e . \`\`\` 4. Если используете Docker, загрузите новый образ и перезапустите контейнер: \`\`\` docker pull agentscope/copaw:latest docker run -p 127.0.0.1:8088:8088 -v copaw-data:/app/working agentscope/copaw:latest \`\`\` После обновления перезапустите сервис с помощью \`copaw app\`.`, en: `### How to update CoPaw To update CoPaw, use the method matching your installation type: 1. If installed via one-line script, re-run the installer to upgrade. 2. If installed via pip, run: \`\`\` pip install --upgrade copaw \`\`\` 3. If installed from source, pull the latest code and reinstall: \`\`\` cd CoPaw git pull origin main pip install -e . \`\`\` 4. If using Docker, pull the latest image and restart the container: \`\`\` docker pull agentscope/copaw:latest docker run -p 127.0.0.1:8088:8088 -v copaw-data:/app/working agentscope/copaw:latest \`\`\` After upgrading, restart the service with \`copaw app\`.`, }; ================================================ FILE: console/src/layouts/index.module.less ================================================ // ─── MainLayout ─────────────────────────────────────────────────────────────── .mainLayout { height: 100vh; } .pageContainer { // inherits from .page-container global class; add local overrides here if needed } // ─── Header ─────────────────────────────────────────────────────────────────── .header { height: 64px; padding: 0 24px; display: flex; align-items: center; justify-content: space-between; background: #fff; border-bottom: 1px solid #f0f0f0; } .headerTitle { font-size: 18px; font-weight: 500; } // ─── Sidebar ────────────────────────────────────────────────────────────────── .sider { background: #fff !important; border-right: 1px solid #f0f0f0; overflow: auto; height: 100vh; :global { .copaw-menu { border-inline-end: 0 !important; } } } .siderDark { background: #1a1a1a !important; border-right-color: rgba(255, 255, 255, 0.08) !important; :global { .copaw-menu { background: #1a1a1a !important; border-inline-end: 0 !important; } /* Group title (sub-menu label) */ .copaw-menu-submenu-title { color: rgba(255, 255, 255, 0.45) !important; } /* Regular menu item */ .copaw-menu-item { color: rgba(255, 255, 255, 0.75) !important; } /* Hover state */ .copaw-menu-item:hover, .copaw-menu-submenu-title:hover { background: rgba(255, 255, 255, 0.06) !important; color: rgba(255, 255, 255, 0.9) !important; } /* Selected item */ .copaw-menu-item-selected { background: rgba(97, 92, 237, 0.2) !important; color: #8b87f0 !important; } } } .siderTop { height: 64px; display: flex; align-items: center; padding: 0 16px; gap: 12px; } .logoWrapper { display: flex; align-items: center; } .logoImg { height: 32px; width: auto; } .versionBadge { font-size: 10px; color: #615ced; font-weight: 600; line-height: 1; position: relative; top: 10px; } .versionBadgeClickable { cursor: pointer; } .versionBadgeDefault { cursor: default; } .collapseBtn { margin: auto; color: #615ced; } // ─── Update Modal ───────────────────────────────────────────────────────────── .updateModalTitle { color: #615ced; } .updateModalBody { max-height: 480px; overflow-y: auto; padding: 8px 4px; min-height: 120px; } .updateModalSpinWrapper { display: flex; justify-content: center; align-items: center; height: 120px; } .updateModalPrimaryBtn { background: #615ced; border-color: #615ced; } // ─── Code block inside update modal ────────────────────────────────────────── .codeBlock { position: relative; background: #f5f5f5; border: 1px solid #e8e8e8; border-radius: 6px; padding: 12px 40px 12px 16px; overflow-x: auto; margin: 8px 0; } .codeBlockInner { font-family: monospace; font-size: 13px; } .codeInline { background: #f5f5f5; border-radius: 3px; padding: 1px 5px; font-family: monospace; font-size: 13px; } // ─── CopyButton ─────────────────────────────────────────────────────────────── .copyBtn { position: absolute; top: 8px; right: 8px; transition: color 0.2s; } .copyBtnCopied { color: #52c41a; } .copyBtnDefault { color: #999; } /* ─── Dark mode overrides ─────────────────────────────────────────────────── */ :global(.dark-mode) { .header { background: #1f1f1f !important; border-bottom-color: rgba(255, 255, 255, 0.08) !important; color: rgba(255, 255, 255, 0.85); /* Text buttons (Docs / FAQ / GitHub / Changelog) */ :global(.copaw-btn-text), :global(.ant-btn-text) { color: rgba(255, 255, 255, 0.75) !important; &:hover { color: #fff !important; background: rgba(255, 255, 255, 0.08) !important; } .anticon, svg { color: inherit !important; } } /* Fallback: any icon or text directly inside header */ :global(.anticon) { color: rgba(255, 255, 255, 0.75); } } .headerTitle { color: rgba(255, 255, 255, 0.85); } .sider { background: #1a1a1a !important; border-right-color: rgba(255, 255, 255, 0.08) !important; } .siderTop { border-bottom: 1px solid rgba(255, 255, 255, 0.06); } .versionBadge { color: #8b87f0; } .collapseBtn { color: #8b87f0; } .codeBlock { background: #2a2a2a; border-color: rgba(255, 255, 255, 0.1); } .codeInline { background: #2a2a2a; color: rgba(255, 255, 255, 0.85); } .copyBtnDefault { color: rgba(255, 255, 255, 0.3); } } ================================================ FILE: console/src/locales/en.json ================================================ { "common": { "save": "Save", "reset": "Reset", "cancel": "Cancel", "confirm": "Confirm", "delete": "Delete", "edit": "Edit", "create": "Create", "upload": "Upload", "download": "Download", "refresh": "Refresh", "enable": "Enable", "disable": "Disable", "enabled": "Enabled", "disabled": "Disabled", "preview": "Preview", "content": "Content", "loading": "Loading...", "copy": "Copy", "copied": "Copied to clipboard", "copyFailed": "Failed to copy to clipboard", "contentPlaceholder": "Enter content...", "help": "Help", "close": "Close", "actions": "Actions", "total": "Total {{count}}" }, "nav": { "chat": "Chat", "control": "Control", "channels": "Channels", "sessions": "Sessions", "cronJobs": "Cron Jobs", "heartbeat": "Heartbeat", "agent": "Agent", "workspace": "Workspace", "skills": "Skills", "tools": "Tools", "mcp": "MCP", "agentConfig": "Configuration", "agents": "Agent Management", "settings": "Settings", "models": "Models", "environments": "Environments", "security": "Security", "tokenUsage": "Token Usage", "voiceTranscription": "Voice Transcription" }, "voiceTranscription": { "title": "Voice Transcription", "description": "Configure how incoming audio and voice messages are handled.", "loadFailed": "Failed to load audio mode settings", "saveSuccess": "Audio mode saved", "saveFailed": "Failed to save audio mode", "audioModeLabel": "Audio Mode", "audioModeDescription": "Choose how voice messages from channels (Discord, Telegram, etc.) are processed before being sent to the model.", "modeAuto": "Auto (Recommended)", "modeAutoDesc": "Transcribe audio to text using the selected transcription provider, then send the text to the model. If transcription is unavailable or disabled, a file-uploaded placeholder is shown instead. Audio is never sent directly to the model in this mode. Works with all models.", "modeNative": "Native Audio", "modeNativeDesc": "Send the audio file directly to the model without transcription. This is the only mode that sends audio to the model. Only works with specific audio-capable models (e.g. gpt-4o-audio). Most models do not support this and will reject the message.", "ffmpegReady": "ffmpeg is installed. Audio conversion is available for native mode.", "ffmpegMissing": "ffmpeg is not installed.", "ffmpegMissingDesc": "Native audio mode requires ffmpeg to convert audio formats (e.g. .ogg to .wav). Install ffmpeg as a system package to enable this mode.", "providerTypeLabel": "Transcription Provider", "providerTypeDescription": "Choose the transcription backend. Select Disabled if you do not need voice transcription.", "providerTypeDisabled": "Disabled", "providerTypeDisabledDesc": "No transcription. Voice messages will show a file-uploaded placeholder.", "providerTypeWhisperApi": "Whisper API", "providerTypeWhisperApiDesc": "Use an OpenAI-compatible Whisper API endpoint from a configured provider (e.g. OpenAI, Ollama).", "providerTypeLocalWhisper": "Local Whisper", "providerTypeLocalWhisperDesc": "Run transcription locally using the openai-whisper Python library. Requires both ffmpeg and openai-whisper to be installed.", "localWhisperReady": "Local Whisper is ready. Both ffmpeg and openai-whisper are installed.", "localWhisperMissing": "Local Whisper is not ready. Missing dependencies must be installed.", "localWhisperMissingDesc": "ffmpeg: {{ffmpeg}} | openai-whisper: {{whisper}}. Install missing dependencies: ffmpeg (system package) and openai-whisper (uv pip install openai-whisper, or install CoPaw with the [whisper] extra).", "providerLabel": "Whisper API Provider", "providerDescription": "Select which provider to use for audio transcription via the Whisper API. Only providers with a Whisper-compatible endpoint are shown.", "providerPlaceholder": "Select a provider...", "noProvidersWarning": "No transcription-capable providers found. Configure an OpenAI provider to enable voice transcription.", "transcriptionInfoTitle": "How transcription works", "transcriptionInfoDesc": "Whisper API transcription uses an OpenAI-compatible /v1/audio/transcriptions endpoint. This requires a configured provider with a Whisper-compatible endpoint — for example, an OpenAI provider. Select a specific provider above to enable transcription.", "transcriptionInfoDescLocal": "Local Whisper transcription runs the openai-whisper library directly on your machine. It requires both ffmpeg (for audio decoding) and the openai-whisper Python package to be installed. No API key or network connection is needed. Install with: uv pip install 'copaw[whisper]'." }, "workspace": { "title": "WorkSpace", "workspacePath": "Workspace:", "noFiles": "No files", "coreFiles": "Core Files", "coreFilesDesc": "Bootstrap persona, identity, and tool guidance.", "uploadTooltip": "Bootstrap persona, identity, and tool guidance. (ZIP files only, max 100MB)", "selectFile": "Select a file to edit", "fileContent": "File content...", "uploadSuccess": "File uploaded successfully", "uploadFailed": "File upload failed", "downloadSuccess": "Workspace downloaded successfully", "downloadFailed": "Workspace download failed", "zipOnly": "Only .zip files are supported for upload", "fileSizeLimit": "File size exceeds 100MB limit. Current file: {{size}}MB", "systemPromptToggleTooltip": "Enable/disable this file in system prompt", "memoryFileWarning": "MEMORY.md is typically queried on-demand by the agent using tools. Loading it into the system prompt may cause the context to become too long.", "configUpdated": "System prompt configuration updated", "configUpdateFailed": "Failed to update system prompt configuration", "attribution": "Workspace design partly inspired by the OpenClaw project — thank you! 🐾", "saveSuccess": "Saved successfully", "saveFailed": "Save failed" }, "agent": { "management": "Agent Management", "pageDescription": "Create, configure, and manage multiple AI agents with custom workspaces and identities.", "name": "Name", "id": "ID", "description": "Description", "workspace": "Workspace Path", "create": "Create Agent", "createTitle": "Create New Agent", "createSuccess": "Agent created successfully", "createFailed": "Failed to create agent", "edit": "Edit", "editTitle": "Edit Agent - {{name}}", "updateSuccess": "Agent updated successfully", "updateFailed": "Failed to update agent", "delete": "Delete", "deleteConfirm": "Confirm Delete Agent", "deleteConfirmDesc": "Agent will be unavailable after deletion, but workspace files will be kept", "deleteSuccess": "Agent deleted successfully", "deleteFailed": "Failed to delete agent", "selectAgent": "Select Agent", "currentWorkspace": "Current Agent", "switchSuccess": "Agent switched successfully", "switchFailed": "Failed to switch agent", "loadFailed": "Failed to load agent list", "loadConfigFailed": "Failed to load agent config", "saveFailed": "Failed to save agent", "idRequired": "Please enter agent ID", "idPattern": "ID can only contain lowercase letters, numbers, underscores and hyphens", "idPlaceholder": "e.g.: my-agent", "nameRequired": "Please enter agent name", "namePlaceholder": "e.g.: My Agent", "descriptionPlaceholder": "Briefly describe this agent's purpose...", "workspaceHelp": "Leave empty to auto-generate in ~/.copaw/workspaces/", "defaultNotEditable": "Default agent cannot be edited", "defaultNotDeletable": "Default agent cannot be deleted" }, "skills": { "title": "Skills", "description": "Manage agent skills and capabilities.", "importSkills": "Import Skill", "enterSkillUrl": "Enter Skill URL", "supportedSkillUrlSources": "Currently supported skill URL sources:", "urlExamples": "URL examples:", "invalidSkillUrlSource": "Skill URL currently needs to start with https://skills.sh/, https://clawhub.ai/, https://skillsmp.com/, https://lobehub.com/, https://market.lobehub.com/, https://github.com/, or https://modelscope.cn/skills/", "source": "Source", "path": "Path", "skillDescription": "Description", "createSkill": "Create Skill", "viewSkill": "View Skill", "editSkill": "Edit Skill", "skillName": "Skill Name", "skillContent": "Skill Content", "pleaseInputName": "Please input skill name", "pleaseInputContent": "Please input skill content", "skillNamePlaceholder": "e.g., weather_query", "contentPlaceholder": "[Format]\n---\nname: skill_name (required, lowercase with underscores)\ndescription: Brief description (required)\n---\n\nSkill implementation (Markdown format)\n\n[Example]\n---\nname: weather_query\ndescription: Query weather info for a city\n---\n\n## Features\nQuery real-time weather data.\n\n## Usage\nUser inputs city name, returns weather info.", "createSuccess": "Skill created successfully", "createFailed": "Failed to create skill", "deleteConfirm": "Are you sure you want to delete this skill?", "deleteSuccess": "Skill deleted successfully", "deleteFailed": "Failed to delete skill", "updateSuccess": "Skill updated successfully", "updateFailed": "Failed to update skill", "frontmatterRequired": "Skills must start and end with ---", "frontmatterNameRequired": "Skills missing required field : name", "frontmatterDescriptionRequired": "Skills missing required field : description", "editNotSupported": "Edit operation is not supported by backend API", "editNote": "Note: Backend API does not support editing skills. You can only view or toggle enable/disable status.", "create": "Create", "optimizeWithAI": "AI Optimize", "stopOptimize": "Stop", "optimizeSuccess": "Skill optimized successfully", "optimizeFailed": "Failed to optimize skill", "noContentToOptimize": "No content to optimize", "cancelImport": "Cancel Import", "importCancelled": "Skill import cancelled", "importTimeout": "Skill import timed out. Please retry or check your network.", "uploadSkill": "Upload Skill", "uploadSuccess": "Skill uploaded successfully", "uploadFailed": "Skill upload failed", "uploadNoChange": "No new skills were imported. They may already exist.", "zipOnly": "Only .zip files are supported for upload", "fileSizeExceeded": "File size exceeds 100MB limit. Current file: {{size}}MB" }, "mcp": { "title": "MCP Clients", "description": "Manage Model Context Protocol (MCP) clients for extending agent capabilities.", "create": "Create Client", "formatSupport": "Supported formats", "emptyState": "No MCP clients configured yet", "loadError": "Failed to load MCP clients", "createSuccess": "MCP client created successfully", "createError": "Failed to create MCP client", "updateSuccess": "MCP client updated successfully", "updateError": "Failed to update MCP client", "enableSuccess": "MCP client enabled successfully", "disableSuccess": "MCP client disabled successfully", "toggleError": "Failed to toggle MCP client status", "deleteConfirm": "Are you sure you want to delete this MCP client?", "deleteSuccess": "MCP client deleted successfully", "deleteError": "Failed to delete MCP client" }, "heartbeat": { "title": "Heartbeat", "description": "Run HEARTBEAT.md at a fixed interval for self-checks. By default runs silently without affecting current conversations, or optionally send replies to the last chat channel.", "enabled": "Enable heartbeat", "every": "Interval", "everyRequired": "Required", "everyMin": "Must be at least 1", "unitMinutes": "Minutes", "unitHours": "Hours", "target": "Reply target", "targetMain": "Silent mode (default, no channel output)", "targetLast": "Send to last chat channel", "activeHours": "Active hours (optional)", "activeStart": "Start time", "activeEnd": "End time", "loadFailed": "Failed to load heartbeat config", "saveSuccess": "Saved successfully; heartbeat hot-reloaded", "saveFailed": "Failed to save heartbeat config" }, "cronJobs": { "title": "Cron Jobs", "description": "Create and manage scheduled tasks that automatically execute at specified times. ", "createJob": "Create Job", "editJob": "Edit Job", "confirmDelete": "Confirm Delete", "deleteConfirm": "Are you sure you want to delete this Cron Job?", "okText": "OK", "cancelText": "Cancel", "deleteText": "Delete", "id": "Job ID", "name": "Job Name", "enabled": "Status", "scheduleType": "Schedule Type", "scheduleCron": "Schedule (Cron)", "scheduleCronLabel": "Schedule (Cron)", "scheduleTimezone": "Timezone", "cronType": "Frequency", "cronTypeHourly": "Hourly", "cronTypeDaily": "Daily", "cronTypeWeekly": "Weekly", "cronTypeCustom": "Custom", "cronTime": "Time", "cronDaysOfWeek": "Days of Week", "cronDayMon": "Monday", "cronDayTue": "Tuesday", "cronDayWed": "Wednesday", "cronDayThu": "Thursday", "cronDayFri": "Friday", "cronDaySat": "Saturday", "cronDaySun": "Sunday", "cronCustomExpression": "Cron Expression", "taskText": "Description", "action": "Action", "disable": "Disable", "executeNow": "Execute Now", "edit": "Edit", "delete": "Delete", "totalItems": "Total {{count}} items", "perPage": "/ page", "pleaseInputId": "Please input job ID", "pleaseInputName": "Please input job name", "pleaseInputCron": "Please input cron expression", "pleaseSelectTaskType": "Please select task type", "pleaseInputRequest": "Please input request content", "pleaseInputChannel": "Please input target channel", "pleaseInputUserId": "Please input target user ID", "pleaseInputSessionId": "Please input target session ID", "jobIdPlaceholder": "e.g., daily-report-job", "jobNamePlaceholder": "e.g., Daily Morning Report", "selectTimezone": "Select timezone", "taskDescriptionPlaceholder": "Brief description of what this job does...", "invalidJsonFormat": "Invalid JSON format", "jsonFormatRequired": "JSON format required", "taskType": "Task Type", "text": "Description", "requestInput": "Request Content", "requestSessionId": "Request Session ID", "requestUserId": "Request User ID", "dispatchChannel": "Target Channel", "dispatchTargetUserId": "Target User ID", "dispatchTargetSessionId": "Target Session ID", "dispatchMode": "Delivery Mode", "runtimeMaxConcurrency": "Max Concurrency", "runtimeTimeoutSeconds": "Timeout (seconds)", "runtimeMisfireGraceSeconds": "Misfire Grace (seconds)", "idTooltip": "Unique identifier for this job. Use lowercase letters, numbers, hyphens, and underscores.", "nameTooltip": "A friendly name to help you identify this job.", "cronTooltip": "Define when the task should run", "cronExample": "Common examples: '0 9 * * *' = 9 AM daily | '*/30 * * * *' = every 30 min | '0 */2 * * *' = every 2 hours | '0 0 * * 0' = Sunday midnight", "cronHelper": "New to Cron expressions?", "cronHelperLink": "Use online generator", "timezoneTooltip": "Timezone for the cron schedule. Default: UTC", "taskTypeTooltip": "Choose 'text' for simple message tasks, or 'agent' for complex agent workflows.", "textTooltip": "Optional description of what this task does. Helpful for documentation.", "requestInputTooltip": "Message content in JSON format. This is what the agent will receive and process.", "requestInputExample": "Format: [{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Your message here\"}]}]", "requestSessionIdTooltip": "Session ID for the request context. Use 'default' if unsure.", "requestUserIdTooltip": "User ID that initiates the request. Use 'system' for automated tasks.", "dispatchChannelTooltip": "Target channel where the response will be sent (e.g., 'console', 'discord', 'imessage').", "dispatchTargetUserIdTooltip": "User ID who will receive the response in the target channel.", "dispatchTargetSessionIdTooltip": "Session ID where the response will be delivered in the target channel.", "dispatchModeTooltip": "Choose 'stream' for real-time responses or 'final' for complete responses only.", "maxConcurrencyTooltip": "Maximum number of this job that can run simultaneously. Default: 1", "timeoutSecondsTooltip": "Maximum execution time in seconds. Job will be terminated if exceeded.", "misfireGraceSecondsTooltip": "Grace period for missed executions. If a job misses its scheduled time by more than this, it won't run.", "executeNowTitle": "Execute Task Now", "executeNowContent": "Are you sure you want to execute \"{{name}}\" now?", "executeNowConfirm": "Execute Now" }, "channels": { "title": "Channels", "description": "Manage and configure message channels", "loading": "Loading channels...", "configSaved": "Configuration saved successfully", "configFailed": "Failed to save configuration", "channelType": "Channel Type", "status": "Status", "totalItems": "Total {{count}} items", "botPrefix": "Bot Prefix", "filterToolMessages": "Show Tool Messages", "filterToolMessagesTooltip": "Show tool call and output messages to users (turn off to hide)", "filterThinking": "Show Thinking", "filterThinkingTooltip": "Show model thinking/reasoning content to users (turn off to hide)", "notSet": "Not set", "clickCardToEdit": "Click card to edit", "settings": "Settings", "channelSettings": "Channel Settings", "pleaseInputDbPath": "Please input DB path", "pleaseInputPollInterval": "Please input poll interval", "discordBotToken": "Discord bot token", "httpProxyPlaceholder": "http://127.0.0.1:18118", "httpProxyAuthPlaceholder": "user:password", "dbPathPlaceholder": "~/Library/Messages/chat.db", "botPrefixPlaceholder": "@bot", "dmPolicy": "DM Policy", "dmPolicyTooltip": "Control who can interact with the bot via direct message. Open: everyone; Allowlist: authorized users only", "groupPolicy": "Group Policy", "groupPolicyTooltip": "Control who can interact with the bot in group chats. Open: everyone; Allowlist: authorized users only", "requireMention": "Require @Mention", "requireMentionTooltip": "When enabled, bot only responds in group chats when explicitly @mentioned", "policyOpen": "Open", "policyAllowlist": "Allowlist", "allowFrom": "Allowlist Users", "allowFromTooltip": "List of user IDs allowed to interact with the bot. Enable allowlist first, then DM the bot to see your ID in the deny message", "allowFromPlaceholder": "Enter user ID and press Enter to add", "denyMessage": "Access Denied Message", "denyMessageTooltip": "Custom message shown to users when access is denied", "twilioAccountSid": "Twilio Account SID", "twilioAuthToken": "Twilio Auth Token", "phoneNumber": "Phone Number", "phoneNumberSid": "Phone Number SID", "phoneNumberSidHelp": "Found under Phone Numbers → Active Numbers in the Twilio Console.", "ttsProvider": "TTS Provider", "ttsVoice": "TTS Voice", "sttProvider": "STT Provider", "language": "Language", "welcomeGreeting": "Welcome Greeting", "welcomeText": "Welcome Message", "welcomeTextTooltip": "Message automatically sent when a user enters a single chat session with the bot for the first time that day", "welcomeTextPlaceholder": "e.g. Hello! I'm CoPaw. How can I help you?", "loginWeCom": "Authorize WeCom Bot via QR Code", "wecomAuthHint": "Click the button to open the WeCom QR code window. After scanning and confirming, Bot ID and Secret will be filled in automatically.", "wecomAuthSuccess": "WeCom bot authorized successfully", "wecomWindowBlocked": "Popup blocked. Please allow popups and try again.", "wecomCancelled": "Authorization cancelled", "wecomAuthFailed": "Authorization failed: {{msg}}", "wecomSdkLoadFailed": "Failed to load WeCom SDK", "voiceSetupGuide": "Set up a Twilio account, purchase a phone number, then enter your credentials below. Find your Account SID and Auth Token on the Twilio Console dashboard. The Phone Number SID is listed under Phone Numbers → Active Numbers.", "voiceSetupLink": "Open Twilio Console", "xiaoyiSetupGuide": "Please create an agent on Huawei Developer Platform and get AK/SK and Agent ID. AK/SK can be found in the credential management page.", "filterAll": "All", "builtin": "Built-in", "custom": "Custom" }, "sessions": { "title": "Sessions", "description": "View and manage active chat sessions", "loading": "Loading sessions...", "confirmDelete": "Confirm Delete", "deleteConfirm": "Are you sure you want to delete this session?", "batchDeleteConfirm": "Are you sure you want to delete selected {{count}} sessions?", "deleteSuccess": "Session deleted successfully", "deleteFailed": "Failed to delete session", "batchDeleteButton": "Batch Delete", "filterUserId": "Filter by User ID", "filterChannel": "Filter by Channel", "allChannels": "All Channels", "totalItems": "Total {{count}} items", "selectedItems": "Selected {{count}} items", "editSession": "Edit Session", "pleaseInputName": "Please input session name", "sessionNamePlaceholder": "Session name" }, "environments": { "title": "Environment Variables", "description": "Configure key-value environment variables for agents and skills.", "key": "Key", "value": "Value", "variableNamePlaceholder": "VARIABLE_NAME", "valuePlaceholder": "value", "insertRowBelow": "Insert row below", "deleteRow": "Delete row", "deleteVariable": "Delete Variable", "deleteConfirm": "Delete \"{{name}}\"?", "deleteSelected": "Delete Selected", "deleteSelectedConfirm": "Delete {{label}}?", "keyRequired": "Key is required", "invalidKeyFormat": "Invalid key format", "duplicateKey": "Duplicate key", "saveSuccess": "Environment variables saved", "saveFailed": "Failed to save", "deleteSuccess": "\"{{name}}\" deleted", "deleteFailed": "Failed to delete", "noVariables": "No environment variables configured yet.", "addVariable": "Add Variable", "loading": "Loading…", "retry": "Retry", "of": "of", "selected": "selected", "variable": "variable", "variables": "variables", "showValue": "Show value", "hideValue": "Hide value" }, "modelSelector": { "selectModel": "Select model", "noConfiguredModels": "No configured models", "switchFailed": "Failed to switch model" }, "tokenUsage": { "title": "Token Usage", "description": "View LLM token consumption over time, by date and model.", "totalTokens": "Total Tokens", "totalCalls": "Total Calls", "promptTokens": "Prompt Tokens", "completionTokens": "Completion Tokens", "byModel": "By Model", "byDate": "By Date", "provider": "Provider", "model": "Model", "date": "Date", "refresh": "Refresh", "loadFailed": "Failed to load token usage", "noData": "No token usage data in the selected period" }, "models": { "llmConfiguration": "LLM Configuration", "providersTitle": "Providers", "providersDescription": "Configure API keys and endpoints for each provider.", "llmTitle": "LLM", "llmDescription": "Choose the active LLM model from an authorized provider.", "configureProvider": "Configure {{name}}", "baseURL": "Base URL", "apiKey": "API Key", "advancedConfig": "Advanced Configuration", "generateConfig": "Generation Parameters", "generateConfigHint": "Generation parameters expressed as a JSON object. They will be expanded and passed into the generation request (`openai.chat.completions` or `anthropic.messages`).", "generateConfigInvalidJson": "Please enter valid JSON", "generateConfigMustBeObject": "Generation parameters must be a JSON object", "protocol": "Protocol", "protocolHint": "Choose the provider API protocol for this configuration.", "selectProtocol": "Please select a protocol", "protocolOpenAI": "OpenAI-compatible (Chat Completions)", "protocolAnthropic": "Anthropic (Messages API)", "currentKey": "Current: {{key}}", "startsWith": "Starts with \"{{prefix}}\"", "optionalSelfHosted": "Optional for self-hosted services", "leaveBlankKeep": "Leave blank to keep current key", "enterApiKey": "Enter API key ({{prefix}}...)", "enterApiKeyOptional": "Enter API key (optional)", "openAIEndpoint": "OpenAI endpoint, e.g. https://api.openai.com/v1", "openAICompatibleEndpoint": "OpenAI-compatible endpoint, e.g. https://api.example.com (append /v1 only if your provider requires it)", "azureEndpointHint": "Azure OpenAI endpoint, e.g. https://.openai.azure.com/openai/v1", "anthropicEndpointHint": "Anthropic endpoint, e.g. https://api.anthropic.com", "ollamaEndpointHint": "Ollama endpoint, e.g. http://localhost:11434", "lmstudioEndpointHint": "LM Studio endpoint, e.g. http://localhost:1234/v1", "apiEndpointHint": "API endpoint, e.g. https://api.example.com", "pleaseEnterBaseURL": "Please enter the API base URL", "pleaseEnterValidURL": "Please enter a valid URL", "apiKeyShouldStart": "API Key should start with \"{{prefix}}\"", "configurationSaved": "{{name}} configuration saved", "failedToSaveConfig": "Failed to save configuration", "revokeAuthorization": "Revoke Authorization", "revokeConfirmContent": "Are you sure you want to remove the API key for {{name}}? The current LLM model configuration will also be cleared.", "revokeConfirmSimple": "Are you sure you want to remove the API key for {{name}}?", "authorizationRevoked": "{{name}} authorization revoked, LLM model cleared", "authorizationRevokedSimple": "{{name}} authorization revoked", "failedToRevoke": "Failed to revoke authorization", "active": "Active: {{provider}} / {{model}}", "provider": "Provider", "model": "Model", "selectProvider": "Select provider (must be authorized)", "selectModel": "Select a model", "llmModelUpdated": "LLM Model updated", "failedToSave": "Failed to save", "saved": "Saved", "save": "Save", "cancel": "Cancel", "loading": "Loading...", "retry": "Retry", "notSet": "Not set", "available": "Available", "unavailable": "Unavailable", "providerAvailable": "Ready (with models)", "providerNoModels": "Not Ready (no models)", "providerNotConfigured": "Not Ready (not configured)", "settings": "Settings", "actions": "Actions", "custom": "Custom", "builtin": "Built-in", "addProvider": "Add Provider", "addProviderTitle": "Add Custom Provider", "providerIdLabel": "Provider ID", "providerIdPlaceholder": "e.g. openai, google, anthropic", "providerIdHint": "Lowercase letters, digits, hyphens, underscores. Cannot be changed later.", "providerNameLabel": "Display Name", "providerNamePlaceholder": "e.g. OpenAI, Google Gemini", "defaultBaseUrlLabel": "Default Base URL", "defaultBaseUrlPlaceholder": "e.g. https://api.example.com", "apiKeyPrefixLabel": "API Key Prefix (optional)", "apiKeyPrefixPlaceholder": "e.g. sk-", "providerCreated": "Provider \"{{name}}\" created", "providerCreateFailed": "Failed to create provider", "deleteProvider": "Delete Provider", "deleteProviderConfirm": "Delete custom provider \"{{name}}\" and all its models? This cannot be undone.", "providerDeleted": "Provider \"{{name}}\" deleted", "providerDeleteFailed": "Failed to delete provider", "manageModels": "Models", "manageModelsTitle": "{{provider}} — Model Management", "userAdded": "User-added", "addModel": "Add Model", "addModelTitle": "Add Model to {{provider}}", "modelIdLabel": "Model ID", "modelIdPlaceholder": "e.g. gpt-4o, gemini-2.0-flash", "modelNameLabel": "Model Name", "modelNameRequired": "Please enter model name", "modelNamePlaceholder": "e.g. GPT-4o, Gemini 2.0 Flash", "modelAdded": "Model \"{{name}}\" added", "modelAddFailed": "Failed to add model", "removeModel": "Remove", "removeModelConfirm": "Remove model \"{{name}}\" from {{provider}}?", "modelRemoved": "Model \"{{name}}\" removed", "modelRemoveFailed": "Failed to remove model", "modelsCount": "{{count}} models", "noModels": "No models", "addModelFirst": "Please add a model first", "local": "Local", "localType": "Type", "localEmbedded": "Embedded (in-process)", "localDownloadFirst": "Download a model first", "localDownloadModel": "Download Model", "localModelsTitle": "{{provider}} — Local Models", "localNoModels": "No downloaded models yet", "localRepoId": "Repository ID", "localRepoIdRequired": "Please enter a repository ID", "localRepoIdPlaceholder": "e.g. TheBloke/Mistral-7B-GGUF", "localFilename": "Filename (optional)", "localFilenamePlaceholder": "e.g. mistral-7b.Q4_K_M.gguf", "localFilenameHint": "Leave empty to auto-select the best quantization", "localSource": "Source", "localDownloadSuccess": "Model downloaded successfully", "localDownloadFailed": "Failed to download model", "localDeleteModel": "Delete Model", "localDeleteConfirm": "Delete local model \"{{name}}\"? The model file will be removed from disk.", "localModelDeleted": "Model \"{{name}}\" deleted", "localDeleteFailed": "Failed to delete model", "localDownloadPending": "Preparing to download...", "localDownloading": "Downloading {{repo}}... This may take a few minutes.", "localDownloadNavigateHint": "You can navigate away — the download will continue in the background.", "localDownloadInProgress": "A download is already in progress", "localCancelDownload": "Cancel Download", "localCancelDownloadConfirm": "Cancel download of \"{{repo}}\"?", "localDownloadCancelled": "Download cancelled", "localCancelDownloadFailed": "Failed to cancel download", "ollamaModelNamePlaceholder": "e.g. mistral:7b, qwen3:8b", "testConnection": "Test Connection", "testConnectionSuccess": "Connection test successful", "testConnectionFailed": "Connection test failed", "testConnectionError": "An error occurred while testing connection", "discoverModels": "Discover Models", "discoverModelsFailed": "Failed to discover models", "ollamaFetchModelsFailed": "Failed to fetch Ollama models. Please check the error details.", "modelTestFailed": "Model validation failed, please check if the model ID is correct", "modelTestFailedConfirm": "Model connection test failed: {{message}}. Do you still want to add this model?", "autoDiscoveredAndAdded": "Auto-discovered {{count}} models and added {{added}} new model(s)", "autoDiscoveredNoNew": "Auto-discovered {{count}} models; model list is already up to date", "autoDiscoverFailed": "Automatic model discovery failed; you can add models manually" }, "agentConfig": { "title": "Configuration", "description": "Configure agent runtime parameters", "reactAgentTitle": "ReAct Agent", "contextManagementTitle": "Context Management", "maxIters": "Max Iterations", "maxItersTooltip": "Maximum number of reasoning-acting iterations for ReAct agent", "maxItersPlaceholder": "Enter max iterations", "maxItersRequired": "Max iterations is required", "maxItersMin": "Max iterations must be at least 1", "maxInputLength": "Max Input Length", "maxInputLengthTooltip": "Maximum input length (tokens) for the model context window", "maxInputLengthPlaceholder": "Enter max input length", "maxInputLengthRequired": "Max input length is required", "maxInputLengthMin": "Max input length must be at least 1000", "contextCompactRatio": "Context Compact Ratio", "contextCompactRatioTooltip": "Ratio of context to compact when context is full", "contextCompactRatioRequired": "Context compact ratio is required", "contextCompactThreshold": "Context Compact Threshold", "contextCompactThresholdTooltip": "Context compact threshold (tokens), auto-calculated from max input length and compact ratio", "contextCompactThresholdPlaceholder": "Auto-calculated", "contextCompactReserveRatio": "Context Reserve Ratio", "contextCompactReserveRatioTooltip": "Ratio of context to reserve when compacting", "contextCompactReserveRatioRequired": "Context reserve ratio is required", "contextCompactReserveThreshold": "Context Reserve Threshold", "contextCompactReserveThresholdTooltip": "Context reserve threshold (tokens), auto-calculated from max input length and reserve ratio", "contextCompactReserveThresholdPlaceholder": "Auto-calculated", "toolResultCompactRecentN": "Tool Result recent_n", "toolResultCompactRecentNTooltip": "Number of tool results to use recent threshold for", "toolResultCompactRecentNRequired": "Tool result recent_n is required", "toolResultCompactOldThreshold": "Max Characters for Tool Results Beyond recent_n", "toolResultCompactOldThresholdTooltip": "Tool results beyond recent_n exceeding this character limit will be compacted", "toolResultCompactOldThresholdPlaceholder": "Enter character threshold", "toolResultCompactOldThresholdRequired": "Max characters for tool results beyond recent_n is required", "toolResultCompactRecentThreshold": "Max Characters for Tool Results Within recent_n", "toolResultCompactRecentThresholdTooltip": "Tool results within recent_n exceeding this character limit will be compacted", "toolResultCompactRecentThresholdPlaceholder": "Enter character threshold", "toolResultCompactRecentThresholdRequired": "Max characters for tool results within recent_n is required", "toolResultCompactRetentionDays": "Tool Result File Retention Days", "toolResultCompactRetentionDaysTooltip": "Number of days to retain compacted tool result files, expired files are auto-cleaned", "toolResultCompactRetentionDaysRequired": "Tool result file retention days is required", "language": "Agent Language", "languageTooltip": "Language for agent persona files (SOUL.md, AGENTS.md, etc.). Changing this will re-copy MD files in the selected language.", "languageConfirmTitle": "Change Agent Language", "languageConfirmContent": "Changing the language will overwrite the following files with the new language version:\n\n SOUL.md, AGENTS.md, PROFILE.md, MEMORY.md, BOOTSTRAP.md, HEARTBEAT.md\n\nIf you have customized any of these files, please back them up first. Your other files will not be affected.", "languageConfirmOk": "Change Language", "languageSaveSuccess": "Language updated successfully", "languageSaveSuccessWithFiles": "Language updated, {{count}} MD file(s) copied", "languageSaveFailed": "Failed to update language", "saveSuccess": "Configuration saved successfully", "saveFailed": "Failed to save configuration", "timezone": "User Timezone", "timezoneTooltip": "Used for scheduled tasks, time display, and agent context. Defaults to your system timezone.", "timezoneRequired": "Please select a timezone", "selectTimezone": "Select timezone", "timezoneSaveSuccess": "Timezone updated successfully", "timezoneSaveFailed": "Failed to update timezone", "loadFailed": "Failed to load configuration" }, "tools": { "title": "Built-in Tools", "description": "Manage built-in tools and their enabled status. Disabled tools will not be available to the agent.", "emptyState": "No tools configured", "loadError": "Failed to load tools", "enableSuccess": "Tool enabled successfully", "disableSuccess": "Tool disabled successfully", "toggleError": "Failed to toggle tool status", "enableAll": "Enable All", "disableAll": "Disable All", "enableAllSuccess": "All tools enabled", "disableAllSuccess": "All tools disabled", "allEnabled": "All tools are already enabled", "allDisabled": "All tools are already disabled" }, "modelConfig": { "promptTitle": "LLM Model Required — Select in Chat After Configuration", "promptMessage": "Chat requires an LLM model to function. Please configure a model first, then select it from the model selector at the top of the Chat page.", "configureButton": "Configure Model", "skipButton": "Skip", "chatDisabledTitle": "Chat Disabled", "chatDisabledMessage": "Chat functionality is disabled because no model is configured. Please configure a model to enable chat.", "configureNow": "Configure Model", "modelNotConfigured": "Please configure a model in Settings before using chat" }, "header": { "changelog": "Changelog", "docs": "Doc", "tutorial": "Tutorial", "faq": "FAQ", "github": "GitHub" }, "theme": { "dark": "Dark", "light": "Light", "darkMode": "Dark mode", "lightMode": "Light mode", "switchToDark": "Switch to dark mode", "switchToLight": "Switch to light mode" }, "sidebar": { "newVersion": "New version available: v{{version}}, click to upgrade", "updateModal": { "title": "New version available: v{{version}}", "viewReleases": "View Releases", "close": "Close" } }, "chat": { "disclaimer": "Works for you, grows with you", "greeting": "Hello, how can I help you today?", "description": "I am a helpful assistant that can help you with your questions.", "prompt1": "Let's start a new journey!", "prompt2": "Can you tell me what skills you have?", "attachments": { "tooltip": "Supports documents, images, videos, audio and more formats, single file up to 10MB", "fileSizeLimit": "Single file cannot exceed 10MB" } }, "security": { "title": "Security", "description": "Manage security features for tools and skills.", "toolGuardTitle": "Tool Guard", "toolGuardDescription": "Configure security scanning for tool calls. Dangerous operations will require your explicit approval before execution.", "enabled": "Enable Tool Guard", "enabledTooltip": "When enabled, tool calls are scanned for dangerous patterns before execution", "guardedTools": "Guarded Tools", "guardedToolsTooltip": "Tools that require approval when dangerous patterns are detected. Leave empty to use the built-in default set.", "guardedToolsPlaceholder": "Select tools or type custom tool names", "deniedTools": "Denied Tools", "deniedToolsTooltip": "Tools that are always rejected without asking for approval", "deniedToolsPlaceholder": "Select tools to always deny", "saveSuccess": "Tool guard settings saved", "saveFailed": "Failed to save tool guard settings", "loadFailed": "Failed to load tool guard settings", "rules": { "title": "Detection Rules", "description": "Rules define regex patterns that trigger security alerts. Built-in rules can be disabled; custom rules can be added, edited, or deleted.", "id": "Rule ID", "severity": "Severity", "descriptionCol": "Description", "source": "Source", "actions": "Actions", "builtin": "Built-in", "custom": "Custom", "enable": "Enable", "disable": "Disable", "edit": "Edit", "delete": "Delete", "add": "Add Rule", "addTitle": "Add Custom Rule", "editTitle": "Edit Custom Rule", "ruleId": "Rule ID", "ruleIdRequired": "Rule ID is required", "duplicateId": "A rule with this ID already exists", "tools": "Target Tools", "toolsPlaceholder": "Leave empty to match all tools", "params": "Target Parameters", "paramsPlaceholder": "Leave empty to match all parameters", "severityLabel": "Severity", "categoryLabel": "Category", "patterns": "Regex Patterns", "patternsRequired": "At least one pattern is required", "patternsTooltip": "One regex per line. Matched against tool parameter values (case-insensitive).", "excludePatterns": "Exclude Patterns", "excludePatternsTooltip": "One regex per line. If any matches, the rule is skipped for that value.", "descriptionLabel": "Description", "descriptionPlaceholder": "What does this rule detect?", "remediationLabel": "Remediation", "remediationPlaceholder": "Suggested action when this rule triggers", "preview": "Preview", "previewTitle": "Rule Details", "actionLabel": "Action", "actionApproval": "Require Approval", "allTools": "All tools", "allParams": "All parameters", "descriptions": { "TOOL_CMD_DANGEROUS_RM": "Detects 'rm' command that may cause data loss", "TOOL_CMD_DANGEROUS_MV": "Detects 'mv' command that may move or overwrite files unexpectedly", "TOOL_CMD_FS_DESTRUCTION": "Detects low-level disk formatting or wiping commands", "TOOL_CMD_DOS_FORK_BOMB": "Detects classic Bash fork bombs and mass process termination", "TOOL_CMD_PIPE_TO_SHELL": "Detects 'curl | bash' patterns used to download and immediately execute remote payloads", "TOOL_CMD_REVERSE_SHELL": "Detects attempts to establish reverse shells or unauthorized network tunnels", "TOOL_CMD_SYSTEM_TAMPERING": "Detects access to cron jobs, SSH keys, or sudo permissions (including reads and modifications)", "TOOL_CMD_UNSAFE_PERMISSIONS": "Detects global permission downgrades (chmod 777) or setting immutable flags", "TOOL_CMD_OBFUSCATED_EXEC": "Detects execution of base64 encoded strings passed directly to a shell interpreter" } }, "skillScanner": { "title": "Skill Scanner", "description": "Automatically scan skills for security threats before enabling or installing. Unsafe skills can be blocked or whitelisted.", "mode": "Scanner Mode", "modeTooltip": "Controls how the scanner handles unsafe skills: block, warn only, or disabled", "modeBlock": "Block", "modeWarn": "Warn Only", "modeOff": "Off", "timeout": "Scan Timeout (seconds)", "timeoutTooltip": "Maximum time to wait for a scan to complete (5-300 seconds)", "saveSuccess": "Skill scanner settings saved", "saveFailed": "Failed to save skill scanner settings", "scanAlerts": { "title": "Scan Alerts", "empty": "No security alerts", "clearAll": "Clear All", "clearConfirm": "Clear all scan alerts?", "skillName": "Skill", "action": "Action", "actionBlocked": "Blocked", "actionWarned": "Warned", "time": "Time", "actions": "Actions", "allowSkill": "Add to Whitelist", "remove": "Remove", "viewFindings": "View Details" }, "whitelist": { "title": "Whitelist", "empty": "No skills are whitelisted", "skillName": "Skill", "addedAt": "Added At", "contentHash": "Content Hash", "actions": "Actions", "remove": "Remove", "removeConfirm": "Remove this skill from the whitelist?", "addSuccess": "Skill added to whitelist", "removeSuccess": "Skill removed from whitelist", "addFailed": "Failed to add skill to whitelist", "removeFailed": "Failed to remove skill from whitelist", "removeWillDisable": "The skill will also be disabled after removal.", "removeAndDisabled": "Skill removed from whitelist and disabled" }, "scanError": { "title": "Security Issues Detected", "description": "The following security issues were found:", "warnDescription": "Security issues were found but the skill was still enabled (warn mode):", "goToWhitelist": "Go to Whitelist" } } }, "login": { "title": "Login to CoPaw", "registerTitle": "Create Account", "firstUserHint": "Create your admin account to get started", "usernamePlaceholder": "Username", "passwordPlaceholder": "Password", "usernameRequired": "Please enter your username", "passwordRequired": "Please enter your password", "submit": "Login", "register": "Register", "failed": "Login failed, please check your credentials", "registerSuccess": "Registration successful", "registerFailed": "Registration failed", "authNotEnabled": "Authentication is not enabled", "logout": "Logout", "logoutSuccess": "Logged out successfully", "sessionExpired": "Session expired, please login again", "unauthorized": "Unauthorized, please login" } } ================================================ FILE: console/src/locales/ja.json ================================================ { "common": { "save": "保存", "reset": "リセット", "cancel": "キャンセル", "confirm": "確認", "delete": "削除", "edit": "編集", "create": "作成", "upload": "アップロード", "download": "ダウンロード", "refresh": "更新", "enable": "有効化", "disable": "無効化", "enabled": "有効", "disabled": "無効", "preview": "プレビュー", "content": "内容", "loading": "読み込み中...", "copy": "コピー", "copied": "クリップボードにコピーしました", "copyFailed": "クリップボードへのコピーに失敗しました", "contentPlaceholder": "内容を入力してください...", "close": "閉じる" }, "nav": { "chat": "チャット", "control": "コントロール", "channels": "チャンネル", "sessions": "セッション", "cronJobs": "定期実行", "heartbeat": "ハートビート", "agent": "エージェント", "workspace": "ワークスペース", "skills": "スキル", "mcp": "MCP", "agentConfig": "設定", "settings": "設定", "models": "モデル", "environments": "環境変数", "security": "セキュリティ", "tokenUsage": "トークン使用量", "tools": "ツール", "voiceTranscription": "音声文字起こし", "agents": "エージェント管理" }, "agent": { "management": "エージェント管理", "pageDescription": "カスタムワークスペースとアイデンティティを持つ複数の AI エージェントを作成、設定、管理します。", "name": "名前", "id": "ID", "description": "説明", "workspace": "ワークスペースパス", "create": "エージェントを作成", "createTitle": "新しいエージェントを作成", "createSuccess": "エージェントが正常に作成されました", "createFailed": "エージェントの作成に失敗しました", "edit": "編集", "editTitle": "エージェントを編集 - {{name}}", "updateSuccess": "エージェントが正常に更新されました", "updateFailed": "エージェントの更新に失敗しました", "delete": "削除", "deleteConfirm": "エージェントの削除を確認", "deleteConfirmDesc": "削除後、エージェントは利用できなくなりますが、ワークスペースファイルは保持されます", "deleteSuccess": "エージェントが正常に削除されました", "deleteFailed": "エージェントの削除に失敗しました", "selectAgent": "エージェントを選択", "currentWorkspace": "現在のエージェント", "switchSuccess": "エージェントが正常に切り替えられました", "switchFailed": "エージェントの切り替えに失敗しました", "loadFailed": "エージェントリストの読み込みに失敗しました", "loadConfigFailed": "エージェント設定の読み込みに失敗しました", "saveFailed": "エージェントの保存に失敗しました", "idRequired": "エージェント ID を入力してください", "idPattern": "ID は小文字、数字、アンダースコア、ハイフンのみ使用できます", "idPlaceholder": "例:my-agent", "nameRequired": "エージェント名を入力してください", "namePlaceholder": "例:マイエージェント", "descriptionPlaceholder": "このエージェントの目的を簡単に説明してください...", "workspaceHelp": "空欄のままにすると、~/.copaw/workspaces/ に自動生成されます", "defaultNotEditable": "デフォルトのエージェントは編集できません", "defaultNotDeletable": "デフォルトのエージェントは削除できません" }, "workspace": { "title": "ワークスペース", "workspacePath": "ワークスペース:", "noFiles": "ファイルなし", "coreFiles": "コアファイル", "coreFilesDesc": "ペルソナ・アイデンティティ・ツールガイダンスを設定します。", "uploadTooltip": "ペルソナ・アイデンティティ・ツールガイダンスを設定します。(ZIPファイルのみ、最大100MB)", "selectFile": "編集するファイルを選択", "fileContent": "ファイルの内容...", "uploadSuccess": "ファイルのアップロードに成功しました", "uploadFailed": "ファイルのアップロードに失敗しました", "downloadSuccess": "ワークスペースのダウンロードに成功しました", "downloadFailed": "ワークスペースのダウンロードに失敗しました", "zipOnly": "アップロードできるのは .zip ファイルのみです", "fileSizeExceeded": "ファイルサイズが100MBの制限を超えています。現在のファイル: {{size}}MB", "systemPromptToggleTooltip": "システムプロンプトへのファイル読み込みを有効/無効にする", "memoryFileWarning": "MEMORY.md は通常、エージェントがツールを使用してオンデマンドで照会および読み込みます。システムプロンプトに読み込むと、コンテキストが長くなりすぎる可能性があります。", "configUpdated": "システムプロンプト設定が更新されました", "configUpdateFailed": "システムプロンプト設定の更新に失敗しました", "attribution": "ワークスペースの設計はOpenClawプロジェクトの一部を参考にしています — ありがとう!🐾" }, "skills": { "title": "スキル", "description": "エージェントのスキルと機能を管理します。", "importSkills": "スキルをインポート", "enterSkillUrl": "スキルのURLを入力", "supportedSkillUrlSources": "現在サポートされているスキルURLのソース:", "urlExamples": "URLの例:", "invalidSkillUrlSource": "スキルURLは https://skills.sh/、https://clawhub.ai/、https://skillsmp.com/、https://lobehub.com/、https://market.lobehub.com/、https://github.com/、または https://modelscope.cn/skills/ で始まる必要があります", "source": "ソース", "path": "パス", "skillDescription": "説明", "createSkill": "スキルを作成", "viewSkill": "スキルを表示", "editSkill": "スキルを編集", "skillName": "スキル名", "skillContent": "スキルの内容", "pleaseInputName": "スキル名を入力してください", "pleaseInputContent": "スキルの内容を入力してください", "skillNamePlaceholder": "例: weather_query", "contentPlaceholder": "---\nname: <スキル名> (必須)\ndescription: <スキルの説明> (必須)\nmetadata: { \"copaw\": { \"emoji\": \"🔧\" } }\n---\n\nスキルの実装内容...\n\n# 例:\n# ---\n# name: cron\n# description: copawコマンド経由でcronジョブを管理 - タスクの作成・照会・一時停止・再開・削除\n# metadata: { \"copaw\": { \"emoji\": \"⏰\" } }\n# ---", "createSuccess": "スキルを作成しました", "createFailed": "スキルの作成に失敗しました", "deleteConfirm": "このスキルを削除してもよろしいですか?", "deleteSuccess": "スキルを削除しました", "deleteFailed": "スキルの削除に失敗しました", "updateSuccess": "スキルを更新しました", "updateFailed": " スキルの更新に失敗しました", "frontmatterRequired": "スキルは --- で始まり --- で終わる必要があります", "frontmatterNameRequired": "スキルに必須フィールド「name」がありません", "frontmatterDescriptionRequired": "スキルに必須フィールド「description」がありません", "editNotSupported": "編集操作はバックエンドAPIでサポートされていません", "editNote": "注意: バックエンドAPIはスキルの編集をサポートしていません。表示または有効/無効の切り替えのみ可能です。", "create": "作成", "cancelImport": "インポートを中止", "importCancelled": "スキルのインポートを中止しました", "importTimeout": "スキルのインポートがタイムアウトしました。再試行するかネットワークを確認してください。", "uploadSkill": "スキルをアップロード", "uploadSuccess": "スキルのアップロードに成功しました", "uploadFailed": "スキルのアップロードに失敗しました", "uploadNoChange": "新しいスキルはインポートされませんでした。既に存在する可能性があります", "zipOnly": "アップロードは .zip ファイルのみ対応しています", "fileSizeExceeded": "ファイルサイズが100MBの制限を超えています。現在のファイル: {{size}}MB" }, "mcp": { "title": "MCPクライアント", "description": "エージェントの機能を拡張するためのMCP(Model Context Protocol)クライアントを管理します。", "create": "クライアントを作成", "formatSupport": "サポートされているフォーマット", "emptyState": "MCPクライアントがまだ設定されていません", "loadError": "MCPクライアントの読み込みに失敗しました", "createSuccess": "MCPクライアントを作成しました", "createError": "MCPクライアントの作成に失敗しました", "updateSuccess": "MCPクライアントを更新しました", "updateError": "MCPクライアントの更新に失敗しました", "enableSuccess": "MCPクライアントを有効化しました", "disableSuccess": "MCPクライアントを無効化しました", "toggleError": "MCPクライアントのステータス変更に失敗しました", "deleteConfirm": "このMCPクライアントを削除してもよろしいですか?", "deleteSuccess": "MCPクライアントを削除しました", "deleteError": "MCPクライアントの削除に失敗しました" }, "heartbeat": { "title": "ハートビート", "description": "一定間隔でHEARTBEAT.mdを実行してセルフチェックを行います。デフォルトでは現在の会話に影響を与えずサイレントに動作しますが、オプションで最後のチャットチャンネルに返信を送ることもできます。", "enabled": "ハートビートを有効化", "every": "間隔", "everyRequired": "必須", "everyMin": "1以上の値が必要です", "unitMinutes": "分", "unitHours": "時間", "target": "返信先", "targetMain": "サイレントモード(デフォルト、チャンネル出力なし)", "targetLast": "最後のチャットチャンネルに送信", "activeHours": "稼働時間(任意)", "activeStart": "開始時刻", "activeEnd": "終了時刻", "loadFailed": "ハートビート設定の読み込みに失敗しました", "saveSuccess": "保存しました。ハートビートをホットリロードしました", "saveFailed": "ハートビート設定の保存に失敗しました" }, "cronJobs": { "title": "定期実行", "description": "指定した時刻に自動実行されるスケジュールタスクを作成・管理します。", "createJob": "ジョブを作成", "editJob": "ジョブを編集", "confirmDelete": "削除の確認", "deleteConfirm": "この定期実行ジョブを削除してもよろしいですか?", "okText": "OK", "cancelText": "キャンセル", "deleteText": "削除", "id": "ジョブID", "name": "ジョブ名", "enabled": "ステータス", "scheduleType": "スケジュールタイプ", "scheduleCron": "スケジュール(Cron)", "scheduleCronLabel": "スケジュール(Cron)", "scheduleTimezone": "タイムゾーン", "cronType": "頻度", "cronTypeHourly": "毎時", "cronTypeDaily": "毎日", "cronTypeWeekly": "毎週", "cronTypeCustom": "カスタム", "cronTime": "時刻", "cronDaysOfWeek": "曜日", "cronDayMon": "月曜日", "cronDayTue": "火曜日", "cronDayWed": "水曜日", "cronDayThu": "木曜日", "cronDayFri": "金曜日", "cronDaySat": "土曜日", "cronDaySun": "日曜日", "cronCustomExpression": "Cron式", "taskText": "説明", "action": "操作", "disable": "無効化", "executeNow": "今すぐ実行", "edit": "編集", "delete": "削除", "totalItems": "合計 {{count}} 件", "perPage": "件/ページ", "pleaseInputId": "ジョブIDを入力してください", "pleaseInputName": "ジョブ名を入力してください", "pleaseInputCron": "Cron式を入力してください", "pleaseSelectTaskType": "タスクタイプを選択してください", "pleaseInputRequest": "リクエスト内容を入力してください", "pleaseInputChannel": "対象チャンネルを入力してください", "pleaseInputUserId": "対象ユーザーIDを入力してください", "pleaseInputSessionId": "対象セッションIDを入力してください", "jobIdPlaceholder": "例: daily-report-job", "jobNamePlaceholder": "例: 毎朝の日報", "selectTimezone": "タイムゾーンを選択", "taskDescriptionPlaceholder": "このジョブの内容を簡単に説明...", "invalidJsonFormat": "JSONフォーマットが不正です", "jsonFormatRequired": "JSONフォーマットが必要です", "taskType": "タスクタイプ", "text": "説明", "requestInput": "リクエスト内容", "requestSessionId": "リクエストセッションID", "requestUserId": "リクエストユーザーID", "dispatchChannel": "対象チャンネル", "dispatchTargetUserId": "対象ユーザーID", "dispatchTargetSessionId": "対象セッションID", "dispatchMode": "配信モード", "runtimeMaxConcurrency": "最大同時実行数", "runtimeTimeoutSeconds": "タイムアウト(秒)", "runtimeMisfireGraceSeconds": "遅延許容時間(秒)", "idTooltip": "このジョブの一意識別子。小文字・数字・ハイフン・アンダースコアが使用できます。", "nameTooltip": "このジョブを識別するためのわかりやすい名前。", "cronTooltip": "タスクの実行タイミングを定義します", "cronExample": "よく使う例: '0 9 * * *' = 毎日9時 | '*/30 * * * *' = 30分ごと | '0 */2 * * *' = 2時間ごと | '0 0 * * 0' = 日曜0時", "cronHelper": "Cron式に慣れていませんか?", "cronHelperLink": "オンラインジェネレーターを使う", "timezoneTooltip": "Cronスケジュールのタイムゾーン。デフォルト: UTC", "taskTypeTooltip": "シンプルなメッセージタスクには「テキスト」を、複雑なエージェントワークフローには「エージェント」を選択してください。", "textTooltip": "このタスクの内容の説明(任意)。ドキュメント用に便利です。", "requestInputTooltip": "JSON形式のメッセージ内容。エージェントが受け取って処理する内容です。", "requestInputExample": "フォーマット: [{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"ここにメッセージを入力\"}]}]", "requestSessionIdTooltip": "リクエストコンテキストのセッションID。不明な場合は「default」を使用してください。", "requestUserIdTooltip": "リクエストを開始するユーザーID。自動タスクには「system」を使用してください。", "dispatchChannelTooltip": "レスポンスを送信する対象チャンネル(例: 'console', 'discord', 'imessage')。", "dispatchTargetUserIdTooltip": "対象チャンネルでレスポンスを受け取るユーザーID。", "dispatchTargetSessionIdTooltip": "対象チャンネルでレスポンスを届けるセッションID。", "dispatchModeTooltip": "リアルタイム応答には「ストリーム」を、完了後の応答には「ファイナル」を選択してください。", "maxConcurrencyTooltip": "このジョブの最大同時実行数。デフォルト: 1", "timeoutSecondsTooltip": "最大実行時間(秒)。超過するとジョブが終了します。", "misfireGraceSecondsTooltip": "実行漏れの許容時間。スケジュール時刻をこの時間以上過ぎた場合は実行されません。", "executeNowTitle": "タスクを今すぐ実行", "executeNowContent": "\"{{name}}\" を今すぐ実行してもよろしいですか?", "executeNowConfirm": "今すぐ実行" }, "channels": { "title": "チャンネル", "description": "メッセージチャンネルの管理と設定", "loading": "チャンネルを読み込み中...", "configSaved": "設定を保存しました", "configFailed": "設定の保存に失敗しました", "channelType": "チャンネルタイプ", "status": "ステータス", "totalItems": "合計 {{count}} 件", "botPrefix": "Botプレフィックス", "filterToolMessages": "ツールメッセージを表示", "filterToolMessagesTooltip": "ツール呼び出しと出力メッセージをユーザーに表示します(オフにすると非表示)", "filterThinking": "思考過程を表示", "filterThinkingTooltip": "モデルの思考・推論内容をユーザーに表示します(オフにすると非表示)", "notSet": "未設定", "clickCardToEdit": "カードをクリックして編集", "settings": "設定", "channelSettings": "チャンネル設定", "pleaseInputDbPath": "DBパスを入力してください", "pleaseInputPollInterval": "ポーリング間隔を入力してください", "discordBotToken": "Discord Botトークン", "httpProxyPlaceholder": "http://127.0.0.1:18118", "httpProxyAuthPlaceholder": "ユーザー:パスワード", "dbPathPlaceholder": "~/Library/Messages/chat.db", "botPrefixPlaceholder": "@bot", "dmPolicy": "DMポリシー", "dmPolicyTooltip": "ダイレクトメッセージでBotと対話できるユーザーを制御します。オープン: 全員、許可リスト: 認証済みユーザーのみ", "groupPolicy": "グループポリシー", "groupPolicyTooltip": "グループチャットでBotと対話できるユーザーを制御します。オープン: 全員、許可リスト: 認証済みユーザーのみ", "requireMention": "@メンション必須", "requireMentionTooltip": "有効にすると、グループチャットでは明示的に@メンションされた場合のみ応答します", "policyOpen": "オープン", "policyAllowlist": "許可リスト", "allowFrom": "許可ユーザー", "allowFromTooltip": "Botと対話できるユーザーIDのリスト(フォーマット: ニックネーム#末尾4桁、例: John#1234)", "allowFromPlaceholder": "ユーザーIDを入力してEnterで追加", "denyMessage": "アクセス拒否メッセージ", "denyMessageTooltip": "アクセスが拒否されたユーザーに表示されるカスタムメッセージ", "twilioAccountSid": "Twilio アカウント SID", "twilioAuthToken": "Twilio 認証トークン", "phoneNumber": "電話番号", "phoneNumberSid": "電話番号 SID", "phoneNumberSidHelp": "TwilioコンソールのPhone Numbers → Active Numbersで確認できます。", "ttsProvider": "TTSプロバイダー", "ttsVoice": "TTSボイス", "sttProvider": "STTプロバイダー", "language": "言語", "welcomeGreeting": "ウェルカムメッセージ", "voiceSetupGuide": "Twilioアカウントを作成して電話番号を取得し、以下に認証情報を入力してください。アカウント SIDと認証トークンはTwilioコンソールのダッシュボードで確認できます。電話番号 SIDはPhone Numbers → Active Numbersに記載されています。", "voiceSetupLink": "Twilioコンソールを開く", "loginWeCom": "QRコードでWeComボットを認証", "wecomAuthHint": "ボタンをクリックするとWeComのQRコードウィンドウが開きます。スキャンして確認後、Bot IDとSecretが自動入力されます。", "wecomAuthSuccess": "WeComボットの認証に成功しました", "wecomWindowBlocked": "ポップアップがブロックされました。ポップアップを許可してから再試行してください。", "wecomCancelled": "認証がキャンセルされました", "wecomAuthFailed": "認証に失敗しました: {{msg}}", "wecomSdkLoadFailed": "WeCom SDKの読み込みに失敗しました", "filterAll": "すべて", "builtin": "組み込み", "custom": "カスタム" }, "sessions": { "title": "セッション", "description": "アクティブなチャットセッションの表示と管理", "loading": "セッションを読み込み中...", "confirmDelete": "削除の確認", "deleteConfirm": "このセッションを削除してもよろしいですか?", "batchDeleteConfirm": "選択した {{count}} 件のセッションを削除してもよろしいですか?", "deleteSuccess": "セッションを削除しました", "deleteFailed": "セッションの削除に失敗しました", "batchDeleteButton": "一括削除", "filterUserId": "ユーザーIDで絞り込み", "filterChannel": "チャンネルで絞り込み", "allChannels": "すべてのチャンネル", "totalItems": "合計 {{count}} 件", "selectedItems": "{{count}} 件選択中", "editSession": "セッションを編集", "pleaseInputName": "セッション名を入力してください", "sessionNamePlaceholder": "セッション名" }, "environments": { "title": "環境変数", "description": "エージェントとスキルのキー・バリュー形式の環境変数を設定します。", "key": "キー", "value": "値", "variableNamePlaceholder": "VARIABLE_NAME", "valuePlaceholder": "値", "insertRowBelow": "下に行を挿入", "deleteRow": "行を削除", "deleteVariable": "変数を削除", "deleteConfirm": "\"{{name}}\" を削除しますか?", "deleteSelected": "選択を削除", "deleteSelectedConfirm": "{{label}} を削除しますか?", "keyRequired": "キーは必須です", "invalidKeyFormat": "キーのフォーマットが不正です", "duplicateKey": "キーが重複しています", "saveSuccess": "環境変数を保存しました", "saveFailed": "保存に失敗しました", "deleteSuccess": "\"{{name}}\" を削除しました", "deleteFailed": "削除に失敗しました", "noVariables": "環境変数がまだ設定されていません。", "addVariable": "変数を追加", "loading": "読み込み中...", "retry": "再試行", "of": "/", "selected": "選択中", "variable": "変数", "variables": "変数", "showValue": "値を表示", "hideValue": "値を非表示" }, "tokenUsage": { "title": "Token 使用量", "description": "日付・モデル別の LLM Token 使用量を確認します。", "totalTokens": "合計 Token 数", "totalCalls": "総呼び出し回数", "promptTokens": "入力 Token", "completionTokens": "出力 Token", "byModel": "モデル別", "byDate": "日付別", "provider": "プロバイダー", "model": "モデル", "date": "日付", "refresh": "更新", "loadFailed": "Token 使用量の読み込みに失敗しました", "noData": "選択期間に Token 使用データがありません" }, "modelSelector": { "selectModel": "モデルを選択", "noConfiguredModels": "設定済みモデルがありません", "switchFailed": "モデルの切り替えに失敗しました" }, "models": { "llmConfiguration": "LLM設定", "providersTitle": "プロバイダー", "providersDescription": "各プロバイダーのAPIキーとエンドポイントを設定します。", "llmTitle": "LLM", "llmDescription": "認証済みプロバイダーからアクティブなLLMモデルを選択します。", "configureProvider": "{{name}} を設定", "baseURL": "ベースURL", "apiKey": "APIキー", "advancedConfig": "詳細設定", "generateConfig": "生成パラメータ設定", "generateConfigHint": "JSON 形式で指定した生成パラメータ設定です。展開されたうえで生成リクエスト(`openai.chat.completions` または `anthropic.messages`)に渡されます。", "generateConfigInvalidJson": "有効な JSON を入力してください", "generateConfigMustBeObject": "生成パラメータ設定は JSON オブジェクトである必要があります", "protocol": "プロトコル", "protocolHint": "この設定のプロバイダーAPIプロトコルを選択してください。", "selectProtocol": "プロトコルを選択してください", "protocolOpenAI": "OpenAI互換(Chat Completions)", "protocolAnthropic": "Anthropic(Messages API)", "currentKey": "現在: {{key}}", "startsWith": "\"{{prefix}}\" で始まる", "optionalSelfHosted": "セルフホストサービスの場合は任意", "leaveBlankKeep": "空欄のままにすると現在のキーを維持します", "enterApiKey": "APIキーを入力({{prefix}}...)", "enterApiKeyOptional": "APIキーを入力(任意)", "openAIEndpoint": "OpenAIエンドポイント、例: https://api.openai.com/v1", "openAICompatibleEndpoint": "OpenAI互換エンドポイント、例: https://api.example.com(プロバイダーが必要とする場合のみ /v1 を追加)", "azureEndpointHint": "Azure OpenAIエンドポイント、例: https://.openai.azure.com/openai/v1", "anthropicEndpointHint": "Anthropicエンドポイント、例: https://api.anthropic.com", "ollamaEndpointHint": "Ollamaエンドポイント、例: http://localhost:11434", "lmstudioEndpointHint": "LM Studioエンドポイント、例: http://localhost:1234/v1", "apiEndpointHint": "APIエンドポイント、例: https://api.example.com", "pleaseEnterBaseURL": "APIベースURLを入力してください", "pleaseEnterValidURL": "有効なURLを入力してください", "apiKeyShouldStart": "APIキーは \"{{prefix}}\" で始まる必要があります", "configurationSaved": "{{name}} の設定を保存しました", "failedToSaveConfig": "設定の保存に失敗しました", "revokeAuthorization": "認証を取り消す", "revokeConfirmContent": "{{name}} のAPIキーを削除してもよろしいですか?現在のLLMモデル設定もクリアされます。", "revokeConfirmSimple": "{{name}} のAPIキーを削除してもよろしいですか?", "authorizationRevoked": "{{name}} の認証を取り消し、LLMモデルをクリアしました", "authorizationRevokedSimple": "{{name}} の認証を取り消しました", "failedToRevoke": "認証の取り消しに失敗しました", "active": "アクティブ: {{provider}} / {{model}}", "provider": "プロバイダー", "model": "モデル", "selectProvider": "プロバイダーを選択(認証済みである必要があります)", "selectModel": "モデルを選択", "llmModelUpdated": "LLMモデルを更新しました", "failedToSave": "保存に失敗しました", "saved": "保存しました", "save": "保存", "cancel": "キャンセル", "loading": "読み込み中...", "retry": "再試行", "notSet": "未設定", "available": "利用可能", "unavailable": "利用不可", "providerAvailable": "準備完了(モデルあり)", "providerNoModels": "未準備(モデルなし)", "providerNotConfigured": "未準備(未設定)", "settings": "設定", "actions": "操作", "custom": "カスタム", "builtin": "組み込み", "addProvider": "プロバイダーを追加", "addProviderTitle": "カスタムプロバイダーを追加", "providerIdLabel": "プロバイダーID", "providerIdPlaceholder": "例: openai, google, anthropic", "providerIdHint": "小文字・数字・ハイフン・アンダースコアが使用できます。後から変更できません。", "providerNameLabel": "表示名", "providerNamePlaceholder": "例: OpenAI, Google Gemini", "defaultBaseUrlLabel": "デフォルトベースURL", "defaultBaseUrlPlaceholder": "例: https://api.example.com", "apiKeyPrefixLabel": "APIキープレフィックス(任意)", "apiKeyPrefixPlaceholder": "例: sk-", "providerCreated": "プロバイダー \"{{name}}\" を作成しました", "providerCreateFailed": "プロバイダーの作成に失敗しました", "deleteProvider": "プロバイダーを削除", "deleteProviderConfirm": "カスタムプロバイダー \"{{name}}\" とすべてのモデルを削除しますか?この操作は取り消せません。", "providerDeleted": "プロバイダー \"{{name}}\" を削除しました", "providerDeleteFailed": "プロバイダーの削除に失敗しました", "manageModels": "モデル", "manageModelsTitle": "{{provider}} — モデル管理", "userAdded": "ユーザー追加", "addModel": "モデルを追加", "addModelTitle": "{{provider}} にモデルを追加", "modelIdLabel": "モデルID", "modelIdPlaceholder": "例: gpt-4o, gemini-2.0-flash", "modelNameLabel": "モデル名", "modelNameRequired": "モデル名を入力してください", "modelNamePlaceholder": "例: GPT-4o, Gemini 2.0 Flash", "modelAdded": "モデル \"{{name}}\" を追加しました", "modelAddFailed": "モデルの追加に失敗しました", "removeModel": "削除", "removeModelConfirm": "{{provider}} からモデル \"{{name}}\" を削除しますか?", "modelRemoved": "モデル \"{{name}}\" を削除しました", "modelRemoveFailed": "モデルの削除に失敗しました", "modelsCount": "{{count}} モデル", "noModels": "モデルなし", "addModelFirst": "先にモデルを追加してください", "local": "ローカル", "localType": "タイプ", "localEmbedded": "組み込み(インプロセス)", "localDownloadFirst": "先にモデルをダウンロードしてください", "localDownloadModel": "モデルをダウンロード", "localModelsTitle": "{{provider}} — ローカルモデル", "localNoModels": "まだダウンロード済みモデルがありません", "localRepoId": "リポジトリID", "localRepoIdRequired": "リポジトリIDを入力してください", "localRepoIdPlaceholder": "例: TheBloke/Mistral-7B-GGUF", "localFilename": "ファイル名(任意)", "localFilenamePlaceholder": "例: mistral-7b.Q4_K_M.gguf", "localFilenameHint": "空欄にすると最適な量子化が自動選択されます", "localSource": "ソース", "localDownloadSuccess": "モデルのダウンロードに成功しました", "localDownloadFailed": "モデルのダウンロードに失敗しました", "localDeleteModel": "モデルを削除", "localDeleteConfirm": "ローカルモデル \"{{name}}\" を削除しますか?モデルファイルはディスクから削除されます。", "localModelDeleted": "モデル \"{{name}}\" を削除しました", "localDeleteFailed": "モデルの削除に失敗しました", "localDownloadPending": "ダウンロードの準備中...", "localDownloading": "{{repo}} をダウンロード中... 数分かかる場合があります。", "localDownloadNavigateHint": "他のページに移動しても構いません。ダウンロードはバックグラウンドで継続されます。", "localDownloadInProgress": "すでにダウンロードが進行中です", "localCancelDownload": "ダウンロードをキャンセル", "localCancelDownloadConfirm": "\"{{repo}}\" のダウンロードをキャンセルしますか?", "localDownloadCancelled": "ダウンロードをキャンセルしました", "localCancelDownloadFailed": "ダウンロードのキャンセルに失敗しました", "ollamaModelNamePlaceholder": "例: mistral:7b, qwen3:8b", "testConnection": "接続テスト", "testConnectionSuccess": "接続テストに成功しました", "testConnectionFailed": "接続テストに失敗しました", "testConnectionError": "接続テスト中にエラーが発生しました", "discoverModels": "モデルを検出", "discoverModelsFailed": "モデルの検出に失敗しました", "ollamaFetchModelsFailed": "Ollamaモデルの取得に失敗しました。エラーの詳細を確認してください。", "modelTestFailed": "モデルの検証に失敗しました。モデルIDが正しいか確認してください", "modelTestFailedConfirm": "モデルの接続テストに失敗しました: {{message}}。このモデルを追加しますか?", "autoDiscoveredAndAdded": "{{count}} 件のモデルを自動検出し、{{added}} 件の新規モデルを追加しました", "autoDiscoveredNoNew": "{{count}} 件のモデルを自動検出しました。モデルリストはすでに最新です", "autoDiscoverFailed": "モデルの自動検出に失敗しました。手動でモデルを追加できます" }, "agentConfig": { "title": "設定", "description": "エージェントのランタイムパラメーターを設定します", "reactAgentTitle": "ReActエージェント", "contextManagementTitle": "コンテキスト管理", "maxIters": "最大反復回数", "maxItersTooltip": "ReActエージェントの推論・行動サイクルの最大反復回数", "maxItersPlaceholder": "最大反復回数を入力", "maxItersRequired": "最大反復回数は必須です", "maxItersMin": "最大反復回数は1以上である必要があります", "maxInputLength": "最大入力長", "maxInputLengthTooltip": "モデルのコンテキストウィンドウの最大入力長(トークン数)", "maxInputLengthPlaceholder": "最大入力長を入力", "maxInputLengthRequired": "最大入力長は必須です", "maxInputLengthMin": "最大入力長は1000以上である必要があります", "contextCompactRatio": "コンテキスト圧縮比率", "contextCompactRatioTooltip": "コンテキストがいっぱいの時に圧縮するコンテキストの比率", "contextCompactRatioRequired": "コンテキスト圧縮比率は必須です", "contextCompactThreshold": "コンテキスト圧縮閾値", "contextCompactThresholdTooltip": "コンテキスト圧縮閾値(トークン数)、最大入力長と圧縮比率から自動計算", "contextCompactThresholdPlaceholder": "自動計算", "contextCompactReserveRatio": "コンテキスト予約比率", "contextCompactReserveRatioTooltip": "コンテキスト圧縮時に予約する比率", "contextCompactReserveRatioRequired": "コンテキスト予約比率は必須です", "contextCompactReserveThreshold": "コンテキスト予約閾値", "contextCompactReserveThresholdTooltip": "コンテキスト予約閾値(トークン数)、最大入力長と予約比率から自動計算", "contextCompactReserveThresholdPlaceholder": "自動計算", "toolResultCompactRecentN": "ツール呼び出し結果のrecent_n", "toolResultCompactRecentNTooltip": "最近のしきい値を使用するツール呼び出し結果の数", "toolResultCompactRecentNRequired": "ツール呼び出し結果のrecent_nは必須です", "toolResultCompactOldThreshold": "recent_nを超えるツール呼び出し結果の最大文字数", "toolResultCompactOldThresholdTooltip": "recent_n範囲外のツール呼び出し結果で、この文字数を超えると圧縮されます", "toolResultCompactOldThresholdPlaceholder": "文字数しきい値を入力", "toolResultCompactOldThresholdRequired": "recent_nを超えるツール呼び出し結果の最大文字数は必須です", "toolResultCompactRecentThreshold": "recent_n内のツール呼び出し結果の最大文字数", "toolResultCompactRecentThresholdTooltip": "recent_n範囲内のツール呼び出し結果で、この文字数を超えると圧縮されます", "toolResultCompactRecentThresholdPlaceholder": "文字数しきい値を入力", "toolResultCompactRecentThresholdRequired": "recent_n内のツール呼び出し結果の最大文字数は必須です", "toolResultCompactRetentionDays": "ツール呼び出しファイルの保持日数", "toolResultCompactRetentionDaysTooltip": "圧縮されたツール結果ファイルの保持日数、期限切れは自動削除されます", "toolResultCompactRetentionDaysRequired": "ツール呼び出しファイルの保持日数は必須です", "language": "エージェント言語", "languageTooltip": "エージェントのペルソナファイル(SOUL.md、AGENTS.md など)の言語。変更すると選択した言語のMDファイルが再コピーされます。", "languageConfirmTitle": "エージェント言語の変更", "languageConfirmContent": "言語を変更すると、以下のファイルが新しい言語のデフォルト版で上書きされます:\n\n SOUL.md、AGENTS.md、PROFILE.md、MEMORY.md、BOOTSTRAP.md、HEARTBEAT.md\n\nカスタマイズ済みのファイルがある場合は、事前にバックアップしてください。その他のファイルは影響を受けません。", "languageConfirmOk": "言語を変更", "languageSaveSuccess": "言語を更新しました", "languageSaveSuccessWithFiles": "言語を更新し、{{count}} 個のMDファイルをコピーしました", "languageSaveFailed": "言語の更新に失敗しました", "timezone": "ユーザータイムゾーン", "timezoneTooltip": "スケジュールタスク、時刻表示、エージェントコンテキストに使用されます。デフォルトはシステムのタイムゾーンです。", "timezoneRequired": "タイムゾーンを選択してください", "selectTimezone": "タイムゾーンを選択", "timezoneSaveSuccess": "タイムゾーンを更新しました", "timezoneSaveFailed": "タイムゾーンの更新に失敗しました", "saveSuccess": "設定を保存しました", "saveFailed": "設定の保存に失敗しました", "loadFailed": "設定の読み込みに失敗しました" }, "tools": { "title": "ビルトインツール", "description": "ビルトインツールとその有効状態を管理します。無効なツールはエージェントで使用できません。", "emptyState": "ツールが設定されていません", "loadError": "ツールの読み込みに失敗しました", "enableSuccess": "ツールを有効化しました", "disableSuccess": "ツールを無効化しました", "toggleError": "ツール状態の切り替えに失敗しました", "enableAll": "すべて有効化", "disableAll": "すべて無効化", "enableAllSuccess": "すべてのツールを有効化しました", "disableAllSuccess": "すべてのツールを無効化しました", "allEnabled": "すべてのツールは既に有効です", "allDisabled": "すべてのツールは既に無効です" }, "modelConfig": { "promptTitle": "LLMモデルが必要です — 設定後にChatページで選択", "promptMessage": "チャット機能にはLLMモデルが必要です。まずモデルを設定し、設定完了後にChatページ上部のモデルセレクターで選択してください。", "configureButton": "モデルを設定", "skipButton": "スキップ", "chatDisabledTitle": "チャット無効", "chatDisabledMessage": "モデルが設定されていないためチャット機能が無効です。チャットを有効にするにはモデルを設定してください。", "configureNow": "モデルを設定", "modelNotConfigured": "チャットを使用する前に設定でモデルを設定してください" }, "header": { "changelog": "変更履歴", "docs": "ドキュメント", "tutorial": "チュートリアル", "faq": "よくある質問", "github": "GitHub" }, "theme": { "dark": "ダーク", "light": "ライト", "darkMode": "ダークモード", "lightMode": "ライトモード", "switchToDark": "ダークモードに切り替え", "switchToLight": "ライトモードに切り替え" }, "sidebar": { "newVersion": "新しいバージョンが利用可能です: v{{version}}、クリックしてアップグレード", "updateModal": { "title": "新しいバージョンが利用可能です: v{{version}}", "viewReleases": "リリースを見る", "close": "閉じる" } }, "chat": { "disclaimer": "あなたのために働き、あなたと共に成長します", "greeting": "こんにちは、今日はどんなお手伝いができますか?", "description": "私はあなたの質問をサポートするAIアシスタントです。", "prompt1": "新しい旅を始めましょう!", "prompt2": "あなたのスキルを教えてください。", "attachments": { "tooltip": "ドキュメント、画像、動画、音声など、様々な形式に対応。1ファイルあたり最大10MB", "fileSizeLimit": "1ファイルあたり最大10MB" } }, "security": { "title": "セキュリティ", "description": "ツールとスキルのセキュリティ機能を管理します。", "toolGuardTitle": "ツールガード", "toolGuardDescription": "ツール呼び出しのセキュリティスキャンを設定します。危険な操作は実行前に明示的な承認が必要になります。", "enabled": "ツールガードを有効にする", "enabledTooltip": "有効にすると、ツール呼び出しが実行前に危険なパターンをスキャンされます", "guardedTools": "保護対象ツール", "guardedToolsTooltip": "危険なパターンが検出された場合に承認が必要なツール。空の場合はデフォルトセットが使用されます。", "guardedToolsPlaceholder": "ツールを選択またはカスタムツール名を入力", "deniedTools": "拒否ツール", "deniedToolsTooltip": "承認なしで常に拒否されるツール", "deniedToolsPlaceholder": "常に拒否するツールを選択", "saveSuccess": "ツールガード設定を保存しました", "saveFailed": "ツールガード設定の保存に失敗しました", "loadFailed": "ツールガード設定の読み込みに失敗しました", "rules": { "title": "検出ルール", "description": "ルールはセキュリティアラートをトリガーする正規表現パターンを定義します。組み込みルールは無効化でき、カスタムルールは追加・編集・削除できます。", "id": "ルール ID", "severity": "重要度", "descriptionCol": "説明", "source": "ソース", "actions": "操作", "builtin": "組み込み", "custom": "カスタム", "enable": "有効化", "disable": "無効化", "edit": "編集", "delete": "削除", "add": "ルール追加", "addTitle": "カスタムルールを追加", "editTitle": "カスタムルールを編集", "ruleId": "ルール ID", "ruleIdRequired": "ルール ID は必須です", "duplicateId": "このIDのルールは既に存在します", "tools": "対象ツール", "toolsPlaceholder": "空白の場合はすべてのツールに一致", "params": "対象パラメータ", "paramsPlaceholder": "空白の場合はすべてのパラメータに一致", "severityLabel": "重要度", "categoryLabel": "カテゴリ", "patterns": "正規表現パターン", "patternsRequired": "パターンが少なくとも1つ必要です", "patternsTooltip": "1行に1つの正規表現。ツールパラメータ値に対してマッチング(大文字小文字区別なし)。", "excludePatterns": "除外パターン", "excludePatternsTooltip": "1行に1つの正規表現。マッチした場合、そのルールはスキップされます。", "descriptionLabel": "説明", "descriptionPlaceholder": "このルールは何を検出しますか?", "remediationLabel": "修正提案", "remediationPlaceholder": "ルールがトリガーされた場合の推奨アクション", "preview": "プレビュー", "previewTitle": "ルール詳細", "actionLabel": "アクション", "actionApproval": "承認待ち", "allTools": "すべてのツール", "allParams": "すべてのパラメータ", "descriptions": { "TOOL_CMD_DANGEROUS_RM": "データ損失を引き起こす可能性のある rm コマンドを検出", "TOOL_CMD_DANGEROUS_MV": "ファイルを意図せず移動・上書きする可能性のある mv コマンドを検出", "TOOL_CMD_FS_DESTRUCTION": "低レベルのディスクフォーマットまたはワイプコマンドを検出", "TOOL_CMD_DOS_FORK_BOMB": "Bash フォーク爆弾および大量プロセス終了を検出", "TOOL_CMD_PIPE_TO_SHELL": "リモートペイロードをダウンロードして即座に実行する 'curl | bash' パターンを検出", "TOOL_CMD_REVERSE_SHELL": "リバースシェルまたは不正なネットワークトンネルの確立を検出", "TOOL_CMD_SYSTEM_TAMPERING": "cron ジョブ、SSH キー、sudo 権限へのアクセス(読み取り・変更を含む)を検出", "TOOL_CMD_UNSAFE_PERMISSIONS": "グローバル権限の引き下げ(chmod 777)や不変フラグの設定を検出", "TOOL_CMD_OBFUSCATED_EXEC": "base64 エンコード文字列をシェルインタプリタに直接渡して実行する操作を検出" } }, "skillScanner": { "title": "スキルスキャナー", "description": "スキルを有効化・インストールする前にセキュリティ脅威を自動スキャンします。", "mode": "スキャンモード", "modeTooltip": "スキャナーが安全でないスキルをどう処理するかを制御: ブロック、警告のみ、オフ", "modeBlock": "ブロック", "modeWarn": "警告のみ", "modeOff": "オフ", "timeout": "スキャンタイムアウト(秒)", "timeoutTooltip": "スキャン完了を待つ最大時間(5-300秒)", "saveSuccess": "スキルスキャナー設定を保存しました", "saveFailed": "スキルスキャナー設定の保存に失敗しました", "scanAlerts": { "title": "スキャンアラート", "empty": "セキュリティアラートはありません", "clearAll": "すべてクリア", "clearConfirm": "すべてのスキャンアラートをクリアしますか?", "skillName": "スキル", "action": "アクション", "actionBlocked": "ブロック済み", "actionWarned": "警告済み", "time": "日時", "actions": "操作", "allowSkill": "ホワイトリストに追加", "remove": "削除", "viewFindings": "詳細を表示" }, "whitelist": { "title": "ホワイトリスト", "empty": "ホワイトリストにスキルがありません", "skillName": "スキル", "addedAt": "追加日時", "contentHash": "コンテンツハッシュ", "actions": "操作", "remove": "削除", "removeConfirm": "このスキルをホワイトリストから削除しますか?", "addSuccess": "スキルをホワイトリストに追加しました", "removeSuccess": "スキルをホワイトリストから削除しました", "addFailed": "ホワイトリストへの追加に失敗しました", "removeFailed": "ホワイトリストからの削除に失敗しました", "removeWillDisable": "削除後、このスキルも無効化されます。", "removeAndDisabled": "スキルをホワイトリストから削除し、無効化しました" }, "scanError": { "title": "セキュリティ問題が検出されました", "description": "以下のセキュリティ問題が見つかりました:", "warnDescription": "セキュリティ問題が見つかりましたが、スキルは引き続き有効です(警告モード):", "goToWhitelist": "ホワイトリストへ" } } }, "login": { "title": "CoPaw にログイン", "registerTitle": "アカウント作成", "firstUserHint": "管理者アカウントを作成して始めましょう", "usernamePlaceholder": "ユーザー名", "passwordPlaceholder": "パスワード", "usernameRequired": "ユーザー名を入力してください", "passwordRequired": "パスワードを入力してください", "submit": "ログイン", "register": "登録", "failed": "ログインに失敗しました。認証情報を確認してください", "registerSuccess": "登録が完了しました", "registerFailed": "登録に失敗しました", "authNotEnabled": "認証は有効になっていません", "logout": "ログアウト", "logoutSuccess": "正常にログアウトしました", "sessionExpired": "セッションが期限切れです。再度ログインしてください", "unauthorized": "未認証です。ログインしてください" }, "voiceTranscription": { "title": "音声文字起こし", "description": "受信した音声メッセージの処理方法を設定します。", "loadFailed": "音声モード設定の読み込みに失敗しました", "saveSuccess": "音声モードを保存しました", "saveFailed": "音声モードの保存に失敗しました", "audioModeLabel": "音声モード", "audioModeDescription": "チャンネル(Discord、Telegram など)からの音声メッセージをモデルに送信する前にどのように処理するかを選択します。", "modeAuto": "自動(推奨)", "modeAutoDesc": "選択した文字起こしプロバイダーで音声をテキストに変換してからモデルに送信します。文字起こしが無効または利用できない場合、ファイルアップロードのプレースホルダーが表示されます。このモードでは音声はモデルに直接送信されません。すべてのモデルで動作します。", "modeNative": "ネイティブ音声", "modeNativeDesc": "文字起こしせずに音声ファイルをモデルに直接送信します。音声をモデルに送信する唯一のモードです。音声対応モデル(例: gpt-4o-audio)でのみ動作します。ほとんどのモデルはこのモードをサポートしていません。", "ffmpegReady": "ffmpegがインストールされています。ネイティブモードの音声変換が利用可能です。", "ffmpegMissing": "ffmpegがインストールされていません。", "ffmpegMissingDesc": "ネイティブ音声モードでは、音声形式の変換(.oggから.wavなど)にffmpegが必要です。このモードを有効にするには、ffmpegをシステムパッケージとしてインストールしてください。", "providerTypeLabel": "文字起こしプロバイダー", "providerTypeDescription": "文字起こしバックエンドを選択します。音声文字起こしが不要な場合は「無効」を選択してください。", "providerTypeDisabled": "無効", "providerTypeDisabledDesc": "文字起こしなし。音声メッセージはファイルアップロードのプレースホルダーとして表示されます。", "providerTypeWhisperApi": "Whisper API", "providerTypeWhisperApiDesc": "設定済みプロバイダー(OpenAI、Ollama など)の OpenAI 互換 Whisper API エンドポイントを使用します。", "providerTypeLocalWhisper": "ローカル Whisper", "providerTypeLocalWhisperDesc": "ローカルにインストールされた openai-whisper Python ライブラリで文字起こしを実行します。ffmpeg と openai-whisper の両方のインストールが必要です。", "localWhisperReady": "ローカル Whisper は準備完了です。ffmpeg と openai-whisper がインストールされています。", "localWhisperMissing": "ローカル Whisper は準備ができていません。不足している依存関係をインストールしてください。", "localWhisperMissingDesc": "ffmpeg: {{ffmpeg}} | openai-whisper: {{whisper}}。不足している依存関係をインストールしてください:ffmpeg(システムパッケージ)と openai-whisper(uv pip install openai-whisper、または CoPaw を [whisper] エクストラ付きでインストール)。", "providerLabel": "Whisper API プロバイダー", "providerDescription": "Whisper API による音声文字起こしに使用するプロバイダーを選択します。Whisper 対応エンドポイントを持つプロバイダーのみ表示されます。", "providerPlaceholder": "プロバイダーを選択...", "noProvidersWarning": "文字起こし対応のプロバイダーが見つかりません。音声文字起こしを有効にするには、OpenAI プロバイダーを設定してください。", "transcriptionInfoTitle": "文字起こしの仕組み", "transcriptionInfoDesc": "Whisper API 文字起こしは OpenAI 互換の /v1/audio/transcriptions エンドポイントを使用します。Whisper 対応エンドポイントを持つプロバイダー(例: OpenAI)の設定が必要です。上記で具体的なプロバイダーを選択して文字起こしを有効にしてください。", "transcriptionInfoDescLocal": "ローカル Whisper 文字起こしは、お使いのマシン上で直接 openai-whisper ライブラリを実行します。ffmpeg(音声デコード用)と openai-whisper Python パッケージの両方のインストールが必要です。API キーやネットワーク接続は不要です。インストール:uv pip install 'copaw[whisper]'。" } } ================================================ FILE: console/src/locales/ru.json ================================================ { "common": { "save": "Сохранить", "reset": "Сбросить", "cancel": "Отмена", "confirm": "Подтвердить", "delete": "Удалить", "edit": "Редактировать", "create": "Создать", "upload": "Загрузить", "download": "Скачать", "refresh": "Обновить", "enable": "Включить", "disable": "Отключить", "enabled": "Включено", "disabled": "Отключено", "preview": "Предпросмотр", "content": "Содержимое", "loading": "Загрузка...", "copy": "Копировать", "copied": "Скопировано в буфер обмена", "copyFailed": "Не удалось скопировать в буфер обмена", "contentPlaceholder": "Введите содержимое...", "close": "Закрыть" }, "nav": { "chat": "Чат", "control": "Управление", "channels": "Каналы", "sessions": "Сессии", "cronJobs": "Cron-задачи", "heartbeat": "Пульс", "agent": "Агент", "workspace": "Рабочее пространство", "skills": "Навыки", "tools": "Инструменты", "mcp": "MCP", "agentConfig": "Конфигурация", "settings": "Настройки", "models": "Модели", "environments": "Окружения", "security": "Безопасность", "tokenUsage": "Использование токенов", "voiceTranscription": "Транскрипция голоса", "agents": "Управление агентами" }, "agent": { "management": "Управление агентами", "pageDescription": "Создавайте, настраивайте и управляйте несколькими AI-агентами с пользовательскими рабочими пространствами и идентичностями.", "name": "Название", "id": "ID", "description": "Описание", "workspace": "Путь к рабочему пространству", "create": "Создать агента", "createTitle": "Создать нового агента", "createSuccess": "Агент успешно создан", "createFailed": "Не удалось создать агента", "edit": "Редактировать", "editTitle": "Редактировать агента - {{name}}", "updateSuccess": "Агент успешно обновлён", "updateFailed": "Не удалось обновить агента", "delete": "Удалить", "deleteConfirm": "Подтвердить удаление агента", "deleteConfirmDesc": "После удаления агент станет недоступен, но файлы рабочего пространства будут сохранены", "deleteSuccess": "Агент успешно удалён", "deleteFailed": "Не удалось удалить агента", "selectAgent": "Выбрать агента", "currentWorkspace": "Текущий агент", "switchSuccess": "Агент успешно переключён", "switchFailed": "Не удалось переключить агента", "loadFailed": "Не удалось загрузить список агентов", "loadConfigFailed": "Не удалось загрузить конфигурацию агента", "saveFailed": "Не удалось сохранить агента", "idRequired": "Пожалуйста, введите ID агента", "idPattern": "ID может содержать только строчные буквы, цифры, подчёркивания и дефисы", "idPlaceholder": "например: my-agent", "nameRequired": "Пожалуйста, введите название агента", "namePlaceholder": "например: Мой агент", "descriptionPlaceholder": "Кратко опишите назначение этого агента...", "workspaceHelp": "Оставьте пустым для автоматического создания в ~/.copaw/workspaces/", "defaultNotEditable": "Агент по умолчанию не может быть отредактирован", "defaultNotDeletable": "Агент по умолчанию не может быть удалён" }, "workspace": { "title": "Рабочее пространство", "workspacePath": "Рабочее пространство:", "noFiles": "Нет файлов", "coreFiles": "Основные файлы", "coreFilesDesc": "Базовые файлы с описанием персоны, идентичности и инструкций по инструментам.", "uploadTooltip": "Базовые файлы с описанием персоны, идентичности и инструкций по инструментам. (Только ZIP-файлы, максимум 100 МБ)", "selectFile": "Выберите файл для редактирования", "fileContent": "Содержимое файла...", "uploadSuccess": "Файл успешно загружен", "uploadFailed": "Не удалось загрузить файл", "downloadSuccess": "Рабочее пространство успешно скачано", "downloadFailed": "Не удалось скачать рабочее пространство", "zipOnly": "Для загрузки поддерживаются только файлы .zip", "fileSizeExceeded": "Размер файла превышает лимит 100 МБ. Текущий файл: {{size}} МБ", "systemPromptToggleTooltip": "Включить/выключить загрузку этого файла в системный промпт", "memoryFileWarning": "MEMORY.md обычно запрашивается агентом по требованию с помощью инструментов. Загрузка его в системный промпт может привести к слишком длинному контексту.", "configUpdated": "Конфигурация системного промпта обновлена", "configUpdateFailed": "Не удалось обновить конфигурацию системного промпта", "attribution": "Дизайн рабочего пространства частично вдохновлён проектом OpenClaw — спасибо! 🐾" }, "skills": { "title": "Навыки", "description": "Управляйте навыками и возможностями агента.", "importSkills": "Импорт навыка", "enterSkillUrl": "Введите URL навыка", "supportedSkillUrlSources": "Сейчас поддерживаются следующие источники URL навыков:", "urlExamples": "Примеры URL:", "invalidSkillUrlSource": "URL навыка должен начинаться с https://skills.sh/, https://clawhub.ai/, https://skillsmp.com/, https://lobehub.com/, https://market.lobehub.com/, https://github.com/ или https://modelscope.cn/skills/", "source": "Источник", "path": "Путь", "skillDescription": "Описание", "createSkill": "Создать навык", "viewSkill": "Просмотреть навык", "editSkill": "Редактировать навык", "skillName": "Название навыка", "skillContent": "Содержимое навыка", "pleaseInputName": "Пожалуйста, введите название навыка", "pleaseInputContent": "Пожалуйста, введите содержимое навыка", "skillNamePlaceholder": "например, weather_query", "contentPlaceholder": "[Формат]\n---\nname: skill_name (обязательно, строчные буквы с подчёркиванием)\ndescription: Краткое описание (обязательно)\n---\n\nРеализация навыка (формат Markdown)\n\n[Пример]\n---\nname: weather_query\ndescription: Запрос информации о погоде для города\n---\n\n## Функции\nЗапрос данных о погоде в реальном времени.\n\n## Использование\nПользователь вводит название города, возвращается информация о погоде.", "createSuccess": "Навык успешно создан", "createFailed": "Не удалось создать навык", "deleteConfirm": "Вы уверены, что хотите удалить этот навык?", "deleteSuccess": "Навык успешно удалён", "deleteFailed": "Не удалось удалить навык", "updateSuccess": "Навык успешно обновлён", "updateFailed": "Не удалось обновить навык", "frontmatterRequired": "Навык должен начинаться и заканчиваться ---", "frontmatterNameRequired": "В навыке отсутствует обязательное поле: name", "frontmatterDescriptionRequired": "В навыке отсутствует обязательное поле: description", "editNotSupported": "Операция редактирования не поддерживается API бэкенда", "editNote": "Примечание: API бэкенда не поддерживает редактирование навыков. Можно только просматривать и переключать статус включения/отключения.", "create": "Создать", "optimizeWithAI": "AI Оптимизация", "stopOptimize": "Стоп", "optimizeSuccess": "Навык успешно оптимизирован", "optimizeFailed": "Не удалось оптимизировать навык", "noContentToOptimize": "Нет содержимого для оптимизации", "cancelImport": "Отменить импорт", "importCancelled": "Импорт навыка отменён", "importTimeout": "Время ожидания импорта навыка истекло. Повторите попытку или проверьте сеть.", "uploadSkill": "Загрузить навык", "uploadSuccess": "Навык успешно загружен", "uploadFailed": "Не удалось загрузить навык", "uploadNoChange": "Новые навыки не были импортированы. Возможно, они уже существуют", "zipOnly": "Поддерживается загрузка только .zip файлов", "fileSizeExceeded": "Размер файла превышает лимит 100МБ. Текущий файл: {{size}}МБ" }, "mcp": { "title": "MCP-клиенты", "description": "Управляйте клиентами Model Context Protocol (MCP) для расширения возможностей агента.", "create": "Создать клиент", "formatSupport": "Поддерживаемые форматы", "emptyState": "MCP-клиенты пока не настроены", "loadError": "Не удалось загрузить MCP-клиентов", "createSuccess": "MCP-клиент успешно создан", "createError": "Не удалось создать MCP-клиент", "updateSuccess": "MCP-клиент успешно обновлён", "updateError": "Не удалось обновить MCP-клиент", "enableSuccess": "MCP-клиент успешно включён", "disableSuccess": "MCP-клиент успешно отключён", "toggleError": "Не удалось переключить статус MCP-клиента", "deleteConfirm": "Вы уверены, что хотите удалить этот MCP-клиент?", "deleteSuccess": "MCP-клиент успешно удалён", "deleteError": "Не удалось удалить MCP-клиент" }, "heartbeat": { "title": "Пульс", "description": "Запускайте HEARTBEAT.md с фиксированным интервалом для самопроверок. По умолчанию выполняется тихо и не влияет на текущие диалоги, либо может отправлять ответы в последний канал чата.", "enabled": "Включить пульс", "every": "Интервал", "everyRequired": "Обязательно", "everyMin": "Должно быть не меньше 1", "unitMinutes": "Минуты", "unitHours": "Часы", "target": "Цель ответа", "targetMain": "Тихий режим (по умолчанию, без вывода в канал)", "targetLast": "Отправлять в последний канал чата", "activeHours": "Активные часы (необязательно)", "activeStart": "Время начала", "activeEnd": "Время окончания", "loadFailed": "Не удалось загрузить конфигурацию пульса", "saveSuccess": "Успешно сохранено; пульс перезагружен на лету", "saveFailed": "Не удалось сохранить конфигурацию пульса" }, "cronJobs": { "title": "Cron-задачи", "description": "Создавайте и управляйте задачами по расписанию, которые автоматически выполняются в указанное время.", "createJob": "Создать задачу", "editJob": "Редактировать задачу", "confirmDelete": "Подтвердить удаление", "deleteConfirm": "Вы уверены, что хотите удалить эту cron-задачу?", "okText": "OK", "cancelText": "Отмена", "deleteText": "Удалить", "id": "ID задачи", "name": "Название задачи", "enabled": "Статус", "scheduleType": "Тип расписания", "scheduleCron": "Расписание (Cron)", "scheduleCronLabel": "Расписание (Cron)", "scheduleTimezone": "Часовой пояс", "cronType": "Частота", "cronTypeHourly": "Каждый час", "cronTypeDaily": "Ежедневно", "cronTypeWeekly": "Еженедельно", "cronTypeCustom": "Пользовательский", "cronTime": "Время", "cronDaysOfWeek": "Дни недели", "cronDayMon": "Понедельник", "cronDayTue": "Вторник", "cronDayWed": "Среда", "cronDayThu": "Четверг", "cronDayFri": "Пятница", "cronDaySat": "Суббота", "cronDaySun": "Воскресенье", "cronCustomExpression": "Cron-выражение", "taskText": "Описание", "action": "Действие", "disable": "Отключить", "executeNow": "Выполнить сейчас", "edit": "Редактировать", "delete": "Удалить", "totalItems": "Всего {{count}} элементов", "perPage": "/ стр.", "pleaseInputId": "Пожалуйста, введите ID задачи", "pleaseInputName": "Пожалуйста, введите название задачи", "pleaseInputCron": "Пожалуйста, введите cron-выражение", "pleaseSelectTaskType": "Пожалуйста, выберите тип задачи", "pleaseInputRequest": "Пожалуйста, введите содержимое запроса", "pleaseInputChannel": "Пожалуйста, введите целевой канал", "pleaseInputUserId": "Пожалуйста, введите ID целевого пользователя", "pleaseInputSessionId": "Пожалуйста, введите ID целевой сессии", "jobIdPlaceholder": "например, daily-report-job", "jobNamePlaceholder": "например, Ежедневный утренний отчёт", "selectTimezone": "Выберите часовой пояс", "taskDescriptionPlaceholder": "Кратко опишите, что делает эта задача...", "invalidJsonFormat": "Неверный формат JSON", "jsonFormatRequired": "Требуется формат JSON", "taskType": "Тип задачи", "text": "Описание", "requestInput": "Содержимое запроса", "requestSessionId": "ID сессии запроса", "requestUserId": "ID пользователя запроса", "dispatchChannel": "Целевой канал", "dispatchTargetUserId": "ID целевого пользователя", "dispatchTargetSessionId": "ID целевой сессии", "dispatchMode": "Режим доставки", "runtimeMaxConcurrency": "Макс. параллельность", "runtimeTimeoutSeconds": "Таймаут (секунды)", "runtimeMisfireGraceSeconds": "Льготный период пропуска (секунды)", "idTooltip": "Уникальный идентификатор этой задачи. Используйте строчные буквы, цифры, дефисы и подчёркивания.", "nameTooltip": "Понятное имя, чтобы вам было проще найти эту задачу.", "cronTooltip": "Определите, когда задача должна запускаться", "cronExample": "Популярные примеры: '0 9 * * *' = ежедневно в 09:00 | '*/30 * * * *' = каждые 30 мин | '0 */2 * * *' = каждые 2 часа | '0 0 * * 0' = воскресенье, полночь", "cronHelper": "Впервые работаете с cron-выражениями?", "cronHelperLink": "Использовать онлайн-генератор", "timezoneTooltip": "Часовой пояс для cron-расписания. По умолчанию: UTC", "taskTypeTooltip": "Выберите 'text' для простых текстовых задач или 'agent' для сложных сценариев с агентом.", "textTooltip": "Необязательное описание того, что делает задача. Полезно для документации.", "requestInputTooltip": "Содержимое сообщения в формате JSON. Именно это агент получит и обработает.", "requestInputExample": "Формат: [{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Ваше сообщение\"}]}]", "requestSessionIdTooltip": "ID сессии для контекста запроса. Если не уверены, используйте 'default'.", "requestUserIdTooltip": "ID пользователя, который инициирует запрос. Для автоматических задач используйте 'system'.", "dispatchChannelTooltip": "Целевой канал, куда будет отправлен ответ (например, 'console', 'discord', 'imessage').", "dispatchTargetUserIdTooltip": "ID пользователя, который получит ответ в целевом канале.", "dispatchTargetSessionIdTooltip": "ID сессии, куда будет доставлен ответ в целевом канале.", "dispatchModeTooltip": "Выберите 'stream' для ответов в реальном времени или 'final' только для итоговых ответов.", "maxConcurrencyTooltip": "Максимальное количество одновременно выполняемых экземпляров этой задачи. По умолчанию: 1", "timeoutSecondsTooltip": "Максимальное время выполнения в секундах. При превышении задача будет остановлена.", "misfireGraceSecondsTooltip": "Льготный период для пропущенных запусков. Если задача пропустила время запуска больше чем на это значение, она не будет выполнена.", "executeNowTitle": "Выполнить задачу сейчас", "executeNowContent": "Вы уверены, что хотите выполнить «{{name}}» прямо сейчас?", "executeNowConfirm": "Выполнить сейчас" }, "channels": { "title": "Каналы", "description": "Управление и настройка каналов сообщений", "loading": "Загрузка каналов...", "configSaved": "Конфигурация успешно сохранена", "configFailed": "Не удалось сохранить конфигурацию", "channelType": "Тип канала", "status": "Статус", "totalItems": "Всего {{count}} элементов", "botPrefix": "Префикс бота", "filterToolMessages": "Показывать сообщения инструментов", "filterToolMessagesTooltip": "Показывать пользователям вызовы инструментов и их вывод (выключите, чтобы скрыть)", "filterThinking": "Показывать рассуждения", "filterThinkingTooltip": "Показывать пользователям рассуждения модели (выключите, чтобы скрыть)", "notSet": "Не задано", "clickCardToEdit": "Нажмите на карточку для редактирования", "settings": "Настройки", "channelSettings": "Настройки канала", "pleaseInputDbPath": "Пожалуйста, введите путь к БД", "pleaseInputPollInterval": "Пожалуйста, введите интервал опроса", "discordBotToken": "Токен Discord-бота", "httpProxyPlaceholder": "http://127.0.0.1:18118", "httpProxyAuthPlaceholder": "user:password", "dbPathPlaceholder": "~/Library/Messages/chat.db", "botPrefixPlaceholder": "@bot", "dmPolicy": "Политика личных сообщений", "dmPolicyTooltip": "Кто может взаимодействовать с ботом в личных сообщениях. Open: все; Allowlist: только авторизованные пользователи", "groupPolicy": "Политика групп", "groupPolicyTooltip": "Кто может взаимодействовать с ботом в групповых чатах. Open: все; Allowlist: только авторизованные пользователи", "requireMention": "Требовать @упоминание", "requireMentionTooltip": "Бот отвечает в групповых чатах только при явном @упоминании", "policyOpen": "Open", "policyAllowlist": "Allowlist", "allowFrom": "Пользователи allowlist", "allowFromTooltip": "Список ID пользователей, которым разрешено взаимодействовать с ботом. Включите белый список, затем напишите боту в ЛС — ваш ID будет в сообщении об отказе", "allowFromPlaceholder": "Введите ID пользователя и нажмите Enter, чтобы добавить", "denyMessage": "Сообщение об отказе", "denyMessageTooltip": "Пользовательское сообщение для пользователей, которым отказан доступ", "twilioAccountSid": "Twilio Account SID", "twilioAuthToken": "Twilio Auth Token", "phoneNumber": "Номер телефона", "phoneNumberSid": "Phone Number SID", "phoneNumberSidHelp": "Можно найти в разделе Phone Numbers → Active Numbers в Twilio Console.", "ttsProvider": "TTS-провайдер", "ttsVoice": "TTS-голос", "sttProvider": "STT-провайдер", "language": "Язык", "welcomeGreeting": "Приветственное сообщение", "voiceSetupGuide": "Создайте аккаунт Twilio, купите номер телефона и затем введите данные ниже. Account SID и Auth Token находятся на панели Twilio Console. Phone Number SID указан в разделе Phone Numbers → Active Numbers.", "voiceSetupLink": "Открыть Twilio Console", "loginWeCom": "Авторизовать бота WeCom по QR-коду", "wecomAuthHint": "Нажмите кнопку — откроется окно с QR-кодом WeCom. После сканирования Bot ID и Secret будут заполнены автоматически.", "wecomAuthSuccess": "Бот WeCom успешно авторизован", "wecomWindowBlocked": "Всплывающее окно заблокировано. Разрешите всплывающие окна и попробуйте снова.", "wecomCancelled": "Авторизация отменена", "wecomAuthFailed": "Ошибка авторизации: {{msg}}", "wecomSdkLoadFailed": "Не удалось загрузить WeCom SDK", "filterAll": "Все", "builtin": "Встроенные", "custom": "Пользовательские" }, "sessions": { "title": "Сессии", "description": "Просмотр и управление активными чат-сессиями", "loading": "Загрузка сессий...", "confirmDelete": "Подтвердить удаление", "deleteConfirm": "Вы уверены, что хотите удалить эту сессию?", "batchDeleteConfirm": "Вы уверены, что хотите удалить выбранные сессии ({{count}})?", "deleteSuccess": "Сессия успешно удалена", "deleteFailed": "Не удалось удалить сессию", "batchDeleteButton": "Массовое удаление", "filterUserId": "Фильтр по ID пользователя", "filterChannel": "Фильтр по каналу", "allChannels": "Все каналы", "totalItems": "Всего {{count}} элементов", "selectedItems": "Выбрано {{count}} элементов", "editSession": "Редактировать сессию", "pleaseInputName": "Пожалуйста, введите название сессии", "sessionNamePlaceholder": "Название сессии" }, "environments": { "title": "Переменные окружения", "description": "Настройка переменных окружения (ключ-значение) для агентов и навыков.", "key": "Ключ", "value": "Значение", "variableNamePlaceholder": "VARIABLE_NAME", "valuePlaceholder": "value", "insertRowBelow": "Вставить строку ниже", "deleteRow": "Удалить строку", "deleteVariable": "Удалить переменную", "deleteConfirm": "Удалить «{{name}}»?", "deleteSelected": "Удалить выбранные", "deleteSelectedConfirm": "Удалить {{label}}?", "keyRequired": "Ключ обязателен", "invalidKeyFormat": "Неверный формат ключа", "duplicateKey": "Дубликат ключа", "saveSuccess": "Переменные окружения сохранены", "saveFailed": "Не удалось сохранить", "deleteSuccess": "«{{name}}» удалено", "deleteFailed": "Не удалось удалить", "noVariables": "Переменные окружения пока не настроены.", "addVariable": "Добавить переменную", "loading": "Загрузка…", "retry": "Повторить", "of": "из", "selected": "выбрано", "variable": "переменная", "variables": "переменных", "showValue": "Показать значение", "hideValue": "Скрыть значение" }, "tokenUsage": { "title": "Использование токенов", "description": "Просмотр расхода LLM-токенов по датам и моделям.", "totalTokens": "Всего токенов", "totalCalls": "Всего вызовов", "promptTokens": "Входные токены", "completionTokens": "Выходные токены", "byModel": "По модели", "byDate": "По дате", "provider": "Провайдер", "model": "Модель", "date": "Дата", "refresh": "Обновить", "loadFailed": "Не удалось загрузить данные об использовании токенов", "noData": "Нет данных об использовании токенов за выбранный период" }, "modelSelector": { "selectModel": "Выбрать модель", "noConfiguredModels": "Нет настроенных моделей", "switchFailed": "Не удалось переключить модель" }, "models": { "llmConfiguration": "Конфигурация LLM", "providersTitle": "Провайдеры", "providersDescription": "Настройте API-ключи и endpoints для каждого провайдера.", "llmTitle": "LLM", "llmDescription": "Выберите активную LLM-модель от авторизованного провайдера.", "configureProvider": "Настроить {{name}}", "baseURL": "Базовый URL", "apiKey": "API-ключ", "advancedConfig": "Расширенная конфигурация", "generateConfig": "Параметры генерации", "generateConfigHint": "Параметры генерации в формате JSON будут развёрнуты и переданы в запрос на генерацию (`openai.chat.completions` или `anthropic.messages`).", "generateConfigInvalidJson": "Пожалуйста, введите корректный JSON", "generateConfigMustBeObject": "Параметры генерации должны быть JSON-объектом", "protocol": "Протокол", "protocolHint": "Выберите протокол API провайдера для этой конфигурации.", "selectProtocol": "Пожалуйста, выберите протокол", "protocolOpenAI": "OpenAI-совместимый (Chat Completions)", "protocolAnthropic": "Anthropic (Messages API)", "currentKey": "Текущий: {{key}}", "startsWith": "Начинается с «{{prefix}}»", "optionalSelfHosted": "Необязательно для self-hosted сервисов", "leaveBlankKeep": "Оставьте пустым, чтобы сохранить текущий ключ", "enterApiKey": "Введите API-ключ ({{prefix}}...)", "enterApiKeyOptional": "Введите API-ключ (необязательно)", "openAIEndpoint": "OpenAI endpoint, например https://api.openai.com/v1", "openAICompatibleEndpoint": "OpenAI-совместимый endpoint, например https://api.example.com (добавляйте /v1 только если ваш провайдер этого требует)", "azureEndpointHint": "Azure OpenAI endpoint, например https://.openai.azure.com/openai/v1", "anthropicEndpointHint": "Anthropic endpoint, например https://api.anthropic.com", "ollamaEndpointHint": "Ollama endpoint, например http://localhost:11434", "lmstudioEndpointHint": "LM Studio endpoint, например http://localhost:1234/v1", "apiEndpointHint": "API endpoint, например https://api.example.com", "pleaseEnterBaseURL": "Пожалуйста, введите базовый URL API", "pleaseEnterValidURL": "Пожалуйста, введите корректный URL", "apiKeyShouldStart": "API-ключ должен начинаться с «{{prefix}}»", "configurationSaved": "Конфигурация {{name}} сохранена", "failedToSaveConfig": "Не удалось сохранить конфигурацию", "revokeAuthorization": "Отозвать авторизацию", "revokeConfirmContent": "Вы уверены, что хотите удалить API-ключ для {{name}}? Текущая конфигурация LLM-модели также будет очищена.", "revokeConfirmSimple": "Вы уверены, что хотите удалить API-ключ для {{name}}?", "authorizationRevoked": "Авторизация {{name}} отозвана, LLM-модель очищена", "authorizationRevokedSimple": "Авторизация {{name}} отозвана", "failedToRevoke": "Не удалось отозвать авторизацию", "active": "Активно: {{provider}} / {{model}}", "provider": "Провайдер", "model": "Модель", "selectProvider": "Выберите провайдера (должен быть авторизован)", "selectModel": "Выберите модель", "llmModelUpdated": "LLM-модель обновлена", "failedToSave": "Не удалось сохранить", "saved": "Сохранено", "save": "Сохранить", "cancel": "Отмена", "loading": "Загрузка...", "retry": "Повторить", "notSet": "Не задано", "available": "Доступно", "unavailable": "Недоступно", "providerAvailable": "Готово (есть модели)", "providerNoModels": "Не готово (нет моделей)", "providerNotConfigured": "Не готово (не настроено)", "settings": "Настройки", "actions": "Действия", "custom": "Пользовательский", "builtin": "Встроенный", "addProvider": "Добавить провайдера", "addProviderTitle": "Добавить пользовательского провайдера", "providerIdLabel": "ID провайдера", "providerIdPlaceholder": "например: openai, google, anthropic", "providerIdHint": "Строчные буквы, цифры, дефисы и подчёркивания. Нельзя изменить позже.", "providerNameLabel": "Отображаемое имя", "providerNamePlaceholder": "например: OpenAI, Google Gemini", "defaultBaseUrlLabel": "Базовый URL по умолчанию", "defaultBaseUrlPlaceholder": "например: https://api.example.com", "apiKeyPrefixLabel": "Префикс API-ключа (необязательно)", "apiKeyPrefixPlaceholder": "например: sk-", "providerCreated": "Провайдер «{{name}}» создан", "providerCreateFailed": "Не удалось создать провайдера", "deleteProvider": "Удалить провайдера", "deleteProviderConfirm": "Удалить пользовательского провайдера «{{name}}» и все его модели? Это действие нельзя отменить.", "providerDeleted": "Провайдер «{{name}}» удалён", "providerDeleteFailed": "Не удалось удалить провайдера", "manageModels": "Модели", "manageModelsTitle": "{{provider}} — Управление моделями", "userAdded": "Добавлено пользователем", "addModel": "Добавить модель", "addModelTitle": "Добавить модель в {{provider}}", "modelIdLabel": "ID модели", "modelIdPlaceholder": "например: gpt-4o, gemini-2.0-flash", "modelNameLabel": "Название модели", "modelNameRequired": "Пожалуйста, введите название модели", "modelNamePlaceholder": "например: GPT-4o, Gemini 2.0 Flash", "modelAdded": "Модель «{{name}}» добавлена", "modelAddFailed": "Не удалось добавить модель", "removeModel": "Удалить", "removeModelConfirm": "Удалить модель «{{name}}» из {{provider}}?", "modelRemoved": "Модель «{{name}}» удалена", "modelRemoveFailed": "Не удалось удалить модель", "modelsCount": "{{count}} моделей", "noModels": "Нет моделей", "addModelFirst": "Сначала добавьте модель", "local": "Локальные", "localType": "Тип", "localEmbedded": "Встроенная (в процессе)", "localDownloadFirst": "Сначала скачайте модель", "localDownloadModel": "Скачать модель", "localModelsTitle": "{{provider}} — Локальные модели", "localNoModels": "Пока нет скачанных моделей", "localRepoId": "ID репозитория", "localRepoIdRequired": "Пожалуйста, введите ID репозитория", "localRepoIdPlaceholder": "например: TheBloke/Mistral-7B-GGUF", "localFilename": "Имя файла (необязательно)", "localFilenamePlaceholder": "например: mistral-7b.Q4_K_M.gguf", "localFilenameHint": "Оставьте пустым для автоматического выбора лучшей квантизации", "localSource": "Источник", "localDownloadSuccess": "Модель успешно скачана", "localDownloadFailed": "Не удалось скачать модель", "localDeleteModel": "Удалить модель", "localDeleteConfirm": "Удалить локальную модель «{{name}}»? Файл модели будет удалён с диска.", "localModelDeleted": "Модель «{{name}}» удалена", "localDeleteFailed": "Не удалось удалить модель", "localDownloadPending": "Подготовка к загрузке...", "localDownloading": "Загрузка {{repo}}... Это может занять несколько минут.", "localDownloadNavigateHint": "Вы можете перейти на другую страницу — загрузка продолжится в фоне.", "localDownloadInProgress": "Загрузка уже выполняется", "localCancelDownload": "Отменить загрузку", "localCancelDownloadConfirm": "Отменить загрузку «{{repo}}»?", "localDownloadCancelled": "Загрузка отменена", "localCancelDownloadFailed": "Не удалось отменить загрузку", "ollamaModelNamePlaceholder": "например: mistral:7b, qwen3:8b", "testConnection": "Проверить подключение", "testConnectionSuccess": "Проверка подключения прошла успешно", "testConnectionFailed": "Проверка подключения не удалась", "testConnectionError": "Произошла ошибка при проверке подключения", "discoverModels": "Обнаружить модели", "discoverModelsFailed": "Не удалось обнаружить модели", "ollamaFetchModelsFailed": "Не удалось получить список моделей Ollama. Проверьте подробности ошибки.", "modelTestFailed": "Проверка модели не пройдена, проверьте корректность ID модели", "modelTestFailedConfirm": "Тест подключения модели не пройден: {{message}}. Всё равно добавить эту модель?", "autoDiscoveredAndAdded": "Автоматически обнаружено {{count}} моделей и добавлено {{added}} новых", "autoDiscoveredNoNew": "Автоматически обнаружено {{count}} моделей; список моделей уже актуален", "autoDiscoverFailed": "Автоматическое обнаружение моделей не удалось; вы можете добавить модели вручную" }, "agentConfig": { "title": "Конфигурация", "description": "Настройка параметров выполнения агента", "reactAgentTitle": "Агент ReAct", "contextManagementTitle": "Управление контекстом", "maxIters": "Макс. итераций", "maxItersTooltip": "Максимальное число итераций рассуждение-действие для агента ReAct", "maxItersPlaceholder": "Введите макс. число итераций", "maxItersRequired": "Требуется указать макс. число итераций", "maxItersMin": "Макс. число итераций должно быть не меньше 1", "maxInputLength": "Макс. длина входа", "maxInputLengthTooltip": "Максимальная длина входа (токены) для окна контекста модели", "maxInputLengthPlaceholder": "Введите макс. длину входа", "maxInputLengthRequired": "Требуется указать макс. длину входа", "maxInputLengthMin": "Макс. длина входа должна быть не меньше 1000", "contextCompactRatio": "Коэффициент сжатия контекста", "contextCompactRatioTooltip": "Доля контекста для сжатия при заполнении", "contextCompactRatioRequired": "Требуется указать коэффициент сжатия контекста", "contextCompactThreshold": "Порог сжатия контекста", "contextCompactThresholdTooltip": "Порог сжатия контекста (токены), автоматически вычисляется из макс. длины входа и коэффициента сжатия", "contextCompactThresholdPlaceholder": "Автоматически", "contextCompactReserveRatio": "Коэффициент резерва контекста", "contextCompactReserveRatioTooltip": "Доля контекста для резерва при сжатии", "contextCompactReserveRatioRequired": "Требуется указать коэффициент резерва контекста", "contextCompactReserveThreshold": "Порог резерва контекста", "contextCompactReserveThresholdTooltip": "Порог резерва контекста (токены), автоматически вычисляется из макс. длины входа и коэффициента резерва", "contextCompactReserveThresholdPlaceholder": "Автоматически", "toolResultCompactRecentN": "recent_n результатов инструментов", "toolResultCompactRecentNTooltip": "Количество результатов инструментов для использования порога недавних сообщений", "toolResultCompactRecentNRequired": "recent_n результатов инструментов обязательно", "toolResultCompactOldThreshold": "Макс. символов для результатов инструментов за пределами recent_n", "toolResultCompactOldThresholdTooltip": "Результаты инструментов за пределами recent_n, превышающие этот лимит, будут сжаты", "toolResultCompactOldThresholdPlaceholder": "Введите порог символов", "toolResultCompactOldThresholdRequired": "Макс. символов для результатов за пределами recent_n обязательно", "toolResultCompactRecentThreshold": "Макс. символов для результатов инструментов в пределах recent_n", "toolResultCompactRecentThresholdTooltip": "Результаты инструментов в пределах recent_n, превышающие этот лимит, будут сжаты", "toolResultCompactRecentThresholdPlaceholder": "Введите порог символов", "toolResultCompactRecentThresholdRequired": "Макс. символов для результатов в пределах recent_n обязательно", "toolResultCompactRetentionDays": "Дни хранения файлов результатов инструментов", "toolResultCompactRetentionDaysTooltip": "Количество дней хранения сжатых файлов, просроченные файлы удаляются автоматически", "toolResultCompactRetentionDaysRequired": "Дни хранения файлов результатов инструментов обязательны", "language": "Язык агента", "languageTooltip": "Язык файлов персонажа агента (SOUL.md, AGENTS.md и др.). При смене языка MD-файлы будут скопированы на выбранном языке.", "languageConfirmTitle": "Смена языка агента", "languageConfirmContent": "При смене языка следующие файлы будут перезаписаны версией на новом языке:\n\n SOUL.md, AGENTS.md, PROFILE.md, MEMORY.md, BOOTSTRAP.md, HEARTBEAT.md\n\nЕсли вы вносили изменения в эти файлы, сначала сделайте резервную копию. Остальные файлы не будут затронуты.", "languageConfirmOk": "Сменить язык", "languageSaveSuccess": "Язык успешно обновлён", "languageSaveSuccessWithFiles": "Язык обновлён, скопировано файлов: {{count}}", "languageSaveFailed": "Не удалось обновить язык", "timezone": "Часовой пояс пользователя", "timezoneTooltip": "Используется для запланированных задач, отображения времени и контекста агента. По умолчанию — системный часовой пояс.", "timezoneRequired": "Пожалуйста, выберите часовой пояс", "selectTimezone": "Выберите часовой пояс", "timezoneSaveSuccess": "Часовой пояс обновлён", "timezoneSaveFailed": "Не удалось обновить часовой пояс", "saveSuccess": "Конфигурация успешно сохранена", "saveFailed": "Не удалось сохранить конфигурацию", "loadFailed": "Не удалось загрузить конфигурацию" }, "tools": { "title": "Встроенные инструменты", "description": "Управляйте встроенными инструментами и их статусом. Отключённые инструменты не будут доступны агенту.", "emptyState": "Нет настроенных инструментов", "loadError": "Не удалось загрузить инструменты", "enableSuccess": "Инструмент успешно включён", "disableSuccess": "Инструмент успешно отключён", "toggleError": "Не удалось изменить статус инструмента", "enableAll": "Включить все", "disableAll": "Отключить все", "enableAllSuccess": "Все инструменты включены", "disableAllSuccess": "Все инструменты отключены", "allEnabled": "Все инструменты уже включены", "allDisabled": "Все инструменты уже отключены" }, "modelConfig": { "promptTitle": "Требуется LLM-модель — выберите в Chat после настройки", "promptMessage": "Для работы чата нужна LLM-модель. Сначала настройте модель, затем выберите её в селекторе моделей вверху страницы Chat.", "configureButton": "Настроить модель", "skipButton": "Пропустить", "chatDisabledTitle": "Чат отключён", "chatDisabledMessage": "Функции чата отключены, потому что не настроена модель. Настройте модель, чтобы включить чат.", "configureNow": "Настроить модель", "modelNotConfigured": "Перед использованием чата настройте модель в разделе «Настройки»" }, "header": { "changelog": "Список изменений", "docs": "Документация", "tutorial": "Руководство", "faq": "FAQ", "github": "GitHub" }, "theme": { "dark": "Тёмная", "light": "Светлая", "darkMode": "Тёмный режим", "lightMode": "Светлый режим", "switchToDark": "Переключить на тёмный режим", "switchToLight": "Переключить на светлый режим" }, "sidebar": { "newVersion": "Доступна новая версия: v{{version}}, нажмите для обновления", "updateModal": { "title": "Доступна новая версия: v{{version}}", "viewReleases": "Открыть релизы", "close": "Закрыть" } }, "chat": { "disclaimer": "Работает для вас, растёт вместе с вами", "greeting": "Привет, чем я могу помочь вам сегодня?", "description": "Я полезный ассистент, который поможет вам с вашими вопросами.", "prompt1": "Давайте начнём новое путешествие!", "prompt2": "Расскажи мне, какими навыками ты обладаешь?", "attachments": { "tooltip": "Поддерживаются документы, изображения, видео, аудио и другие форматы. Максимум 10 МБ на файл", "fileSizeLimit": "Размер одного файла не может превышать 10 МБ" } }, "security": { "title": "Безопасность", "description": "Управление функциями безопасности инструментов и навыков.", "toolGuardTitle": "Защита инструментов", "toolGuardDescription": "Настройте сканирование безопасности для вызовов инструментов. Опасные операции потребуют вашего явного подтверждения перед выполнением.", "enabled": "Включить защиту инструментов", "enabledTooltip": "При включении вызовы инструментов сканируются на наличие опасных шаблонов перед выполнением", "guardedTools": "Защищённые инструменты", "guardedToolsTooltip": "Инструменты, требующие подтверждения при обнаружении опасных шаблонов. Оставьте пустым для использования набора по умолчанию.", "guardedToolsPlaceholder": "Выберите инструменты или введите пользовательские имена", "deniedTools": "Запрещённые инструменты", "deniedToolsTooltip": "Инструменты, которые всегда отклоняются без запроса подтверждения", "deniedToolsPlaceholder": "Выберите инструменты для запрета", "saveSuccess": "Настройки защиты инструментов сохранены", "saveFailed": "Не удалось сохранить настройки защиты инструментов", "loadFailed": "Не удалось загрузить настройки защиты инструментов", "rules": { "title": "Правила обнаружения", "description": "Правила определяют шаблоны регулярных выражений, которые вызывают оповещения безопасности. Встроенные правила можно отключить; пользовательские правила можно добавлять, редактировать или удалять.", "id": "ID правила", "severity": "Серьёзность", "descriptionCol": "Описание", "source": "Источник", "actions": "Действия", "builtin": "Встроенное", "custom": "Пользовательское", "enable": "Включить", "disable": "Отключить", "edit": "Редактировать", "delete": "Удалить", "add": "Добавить правило", "addTitle": "Добавить пользовательское правило", "editTitle": "Редактировать пользовательское правило", "ruleId": "ID правила", "ruleIdRequired": "ID правила обязателен", "duplicateId": "Правило с таким ID уже существует", "tools": "Целевые инструменты", "toolsPlaceholder": "Оставьте пустым для совпадения со всеми инструментами", "params": "Целевые параметры", "paramsPlaceholder": "Оставьте пустым для совпадения со всеми параметрами", "severityLabel": "Серьёзность", "categoryLabel": "Категория", "patterns": "Шаблоны регулярных выражений", "patternsRequired": "Требуется хотя бы один шаблон", "patternsTooltip": "По одному регулярному выражению в строке. Сопоставляется со значениями параметров инструмента (без учёта регистра).", "excludePatterns": "Шаблоны исключения", "excludePatternsTooltip": "По одному регулярному выражению в строке. Если совпадает, правило пропускается.", "descriptionLabel": "Описание", "descriptionPlaceholder": "Что обнаруживает это правило?", "remediationLabel": "Рекомендация", "remediationPlaceholder": "Рекомендуемое действие при срабатывании правила", "preview": "Просмотр", "previewTitle": "Детали правила", "actionLabel": "Действие", "actionApproval": "Требуется одобрение", "allTools": "Все инструменты", "allParams": "Все параметры", "descriptions": { "TOOL_CMD_DANGEROUS_RM": "Обнаруживает команду rm, которая может привести к потере данных", "TOOL_CMD_DANGEROUS_MV": "Обнаруживает команду mv, которая может неожиданно переместить или перезаписать файлы", "TOOL_CMD_FS_DESTRUCTION": "Обнаруживает команды низкоуровневого форматирования или очистки дисков", "TOOL_CMD_DOS_FORK_BOMB": "Обнаруживает классические Bash fork-бомбы и массовое завершение процессов", "TOOL_CMD_PIPE_TO_SHELL": "Обнаруживает паттерны 'curl | bash' для загрузки и немедленного выполнения удалённых скриптов", "TOOL_CMD_REVERSE_SHELL": "Обнаруживает попытки создания обратных оболочек или несанкционированных сетевых туннелей", "TOOL_CMD_SYSTEM_TAMPERING": "Обнаруживает доступ к заданиям cron, SSH-ключам или правам sudo (включая чтение и изменение)", "TOOL_CMD_UNSAFE_PERMISSIONS": "Обнаруживает глобальное понижение прав доступа (chmod 777) или установку неизменяемых флагов", "TOOL_CMD_OBFUSCATED_EXEC": "Обнаруживает выполнение строк в кодировке base64, передаваемых напрямую в интерпретатор оболочки" } }, "skillScanner": { "title": "Сканер навыков", "description": "Автоматическое сканирование навыков на наличие угроз безопасности перед активацией или установкой.", "mode": "Режим сканирования", "modeTooltip": "Управление поведением сканера: блокировка, только предупреждение или отключение", "modeBlock": "Блокировать", "modeWarn": "Только предупреждение", "modeOff": "Выключен", "timeout": "Таймаут сканирования (секунды)", "timeoutTooltip": "Максимальное время ожидания завершения сканирования (5-300 секунд)", "saveSuccess": "Настройки сканера навыков сохранены", "saveFailed": "Не удалось сохранить настройки сканера навыков", "scanAlerts": { "title": "Оповещения сканирования", "empty": "Нет оповещений безопасности", "clearAll": "Очистить все", "clearConfirm": "Очистить все оповещения сканирования?", "skillName": "Навык", "action": "Действие", "actionBlocked": "Заблокировано", "actionWarned": "Предупреждение", "time": "Время", "actions": "Действия", "allowSkill": "Добавить в белый список", "remove": "Удалить", "viewFindings": "Подробности" }, "whitelist": { "title": "Белый список", "empty": "Белый список пуст", "skillName": "Навык", "addedAt": "Добавлено", "contentHash": "Хеш содержимого", "actions": "Действия", "remove": "Удалить", "removeConfirm": "Удалить этот навык из белого списка?", "addSuccess": "Навык добавлен в белый список", "removeSuccess": "Навык удалён из белого списка", "addFailed": "Не удалось добавить навык в белый список", "removeFailed": "Не удалось удалить навык из белого списка", "removeWillDisable": "После удаления навык также будет отключён.", "removeAndDisabled": "Навык удалён из белого списка и отключён" }, "scanError": { "title": "Обнаружены проблемы безопасности", "description": "Обнаружены следующие проблемы безопасности:", "warnDescription": "Обнаружены проблемы безопасности, но навык был включён (режим предупреждения):", "goToWhitelist": "Перейти к белому списку" } } }, "login": { "title": "Вход в CoPaw", "registerTitle": "Создание аккаунта", "firstUserHint": "Создайте учётную запись администратора для начала работы", "usernamePlaceholder": "Имя пользователя", "passwordPlaceholder": "Пароль", "usernameRequired": "Пожалуйста, введите имя пользователя", "passwordRequired": "Пожалуйста, введите пароль", "submit": "Войти", "register": "Зарегистрироваться", "failed": "Ошибка входа, проверьте учётные данные", "registerSuccess": "Регистрация прошла успешно", "registerFailed": "Ошибка регистрации", "authNotEnabled": "Аутентификация не включена", "logout": "Выйти", "logoutSuccess": "Вы успешно вышли", "sessionExpired": "Сессия истекла, пожалуйста, войдите снова", "unauthorized": "Не авторизован, пожалуйста, войдите" }, "voiceTranscription": { "title": "Транскрипция голоса", "description": "Настройте обработку входящих аудио- и голосовых сообщений.", "loadFailed": "Не удалось загрузить настройки аудиорежима", "saveSuccess": "Аудиорежим сохранён", "saveFailed": "Не удалось сохранить аудиорежим", "audioModeLabel": "Аудиорежим", "audioModeDescription": "Выберите, как голосовые сообщения из каналов (Discord, Telegram и др.) обрабатываются перед отправкой модели.", "modeAuto": "Авто (рекомендуется)", "modeAutoDesc": "Транскрибировать аудио в текст через выбранный провайдер, затем отправить текст модели. Если транскрипция отключена или недоступна, отображается заглушка «файл загружен». В этом режиме аудио не отправляется модели напрямую. Работает со всеми моделями.", "modeNative": "Нативное аудио", "modeNativeDesc": "Отправлять аудиофайл модели напрямую без транскрипции. Это единственный режим, передающий аудио модели. Работает только с моделями, поддерживающими аудио (например, gpt-4o-audio). Большинство моделей не поддерживают этот режим.", "ffmpegReady": "ffmpeg установлен. Конвертация аудио для нативного режима доступна.", "ffmpegMissing": "ffmpeg не установлен.", "ffmpegMissingDesc": "Нативный аудиорежим требует ffmpeg для конвертации аудиоформатов (например, .ogg в .wav). Установите ffmpeg как системный пакет для включения этого режима.", "providerTypeLabel": "Провайдер транскрипции", "providerTypeDescription": "Выберите бэкенд транскрипции. Если голосовая транскрипция не нужна, выберите «Отключено».", "providerTypeDisabled": "Отключено", "providerTypeDisabledDesc": "Без транскрипции. Голосовые сообщения будут отображаться как заглушка «файл загружен».", "providerTypeWhisperApi": "Whisper API", "providerTypeWhisperApiDesc": "Использовать OpenAI-совместимый Whisper API от настроенного провайдера (например, OpenAI, Ollama).", "providerTypeLocalWhisper": "Локальный Whisper", "providerTypeLocalWhisperDesc": "Запускать транскрипцию локально с помощью библиотеки openai-whisper. Требуются ffmpeg и openai-whisper.", "localWhisperReady": "Локальный Whisper готов к работе. ffmpeg и openai-whisper установлены.", "localWhisperMissing": "Локальный Whisper не готов. Необходимо установить недостающие зависимости.", "localWhisperMissingDesc": "ffmpeg: {{ffmpeg}} | openai-whisper: {{whisper}}. Установите недостающие зависимости: ffmpeg (системный пакет) и openai-whisper (uv pip install openai-whisper или установите CoPaw с опцией [whisper]).", "providerLabel": "Провайдер Whisper API", "providerDescription": "Выберите провайдера для транскрипции аудио через Whisper API. Отображаются только провайдеры с совместимым эндпоинтом.", "providerPlaceholder": "Выберите провайдера...", "noProvidersWarning": "Не найдено провайдеров для транскрипции. Настройте провайдер OpenAI для включения голосовой транскрипции.", "transcriptionInfoTitle": "Как работает транскрипция", "transcriptionInfoDesc": "Whisper API транскрипция использует OpenAI-совместимый эндпоинт /v1/audio/transcriptions. Необходим провайдер с поддержкой Whisper (например, OpenAI). Выберите конкретного провайдера выше для включения транскрипции.", "transcriptionInfoDescLocal": "Локальная Whisper транскрипция запускает библиотеку openai-whisper непосредственно на вашем устройстве. Требуются ffmpeg (для декодирования аудио) и пакет openai-whisper. API-ключ и подключение к интернету не нужны. Установка: uv pip install 'copaw[whisper]'." } } ================================================ FILE: console/src/locales/zh.json ================================================ { "common": { "save": "保存", "reset": "重置", "cancel": "取消", "confirm": "确认", "delete": "删除", "edit": "编辑", "create": "创建", "upload": "上传", "download": "下载", "refresh": "刷新", "enable": "启用", "disable": "禁用", "enabled": "已启用", "disabled": "已禁用", "preview": "预览", "content": "内容", "loading": "加载中...", "copy": "复制", "copied": "已复制到剪贴板", "copyFailed": "复制到剪贴板失败", "contentPlaceholder": "输入内容...", "help": "帮助", "close": "关闭", "actions": "操作", "total": "共 {{count}} 条" }, "nav": { "chat": "聊天", "control": "控制", "channels": "频道", "sessions": "会话", "cronJobs": "定时任务", "heartbeat": "心跳", "agent": "智能体", "workspace": "工作区", "skills": "技能", "tools": "工具", "mcp": "MCP", "agentConfig": "运行配置", "agents": "智能体管理", "settings": "设置", "models": "模型", "environments": "环境变量", "security": "安全", "tokenUsage": "Token 消耗", "voiceTranscription": "语音转写" }, "workspace": { "title": "工作区", "workspacePath": "工作区路径:", "noFiles": "没有文件", "coreFiles": "核心文件", "coreFilesDesc": "引导角色、身份和工具指南。", "uploadTooltip": "引导角色、身份和工具指南。(仅支持 ZIP 文件,最大 100MB)", "selectFile": "选择文件进行编辑", "fileContent": "文件内容...", "uploadSuccess": "文件上传成功", "uploadFailed": "文件上传失败", "downloadSuccess": "工作区下载成功", "downloadFailed": "工作区下载失败", "zipOnly": "仅支持上传 .zip 文件", "fileSizeExceeded": "文件大小超过 100MB 限制。当前文件:{{size}}MB", "systemPromptToggleTooltip": "启用/禁用此文件加载到系统提示词", "memoryFileWarning": "MEMORY.md 一般是由 agent 主动调用工具按需查询和加载,放到 system prompt 里面可能会导致上下文过长", "configUpdated": "系统提示词配置已更新", "configUpdateFailed": "更新系统提示词配置失败", "attribution": "工作区设计部分灵感源自 OpenClaw 项目,在此表示感谢 🐾", "saveSuccess": "保存成功", "saveFailed": "保存失败" }, "agent": { "management": "智能体管理", "pageDescription": "创建、配置和管理多个 AI 智能体,支持自定义工作空间和身份。", "name": "名称", "id": "ID", "description": "描述", "workspace": "工作区路径", "create": "创建智能体", "createTitle": "创建新智能体", "createSuccess": "智能体创建成功", "createFailed": "智能体创建失败", "edit": "编辑", "editTitle": "编辑智能体 - {{name}}", "updateSuccess": "智能体更新成功", "updateFailed": "智能体更新失败", "delete": "删除", "deleteConfirm": "确认删除智能体", "deleteConfirmDesc": "删除后智能体将不可用,但工作区文件会保留", "deleteSuccess": "智能体删除成功", "deleteFailed": "智能体删除失败", "selectAgent": "选择智能体", "currentWorkspace": "当前智能体", "switchSuccess": "智能体切换成功", "switchFailed": "智能体切换失败", "loadFailed": "加载智能体列表失败", "loadConfigFailed": "加载智能体配置失败", "saveFailed": "保存智能体失败", "idRequired": "请输入智能体ID", "idPattern": "ID只能包含小写字母、数字、下划线和连字符", "idPlaceholder": "例如:my-agent", "nameRequired": "请输入智能体名称", "namePlaceholder": "例如:我的智能体", "descriptionPlaceholder": "简要描述这个智能体的用途...", "workspaceHelp": "留空将自动生成在 ~/.copaw/workspaces/ 目录", "defaultNotEditable": "默认智能体不允许编辑", "defaultNotDeletable": "默认智能体不允许删除" }, "skills": { "title": "技能", "description": "管理智能体技能和能力。", "importSkills": "导入技能", "enterSkillUrl": "输入技能URL", "supportedSkillUrlSources": "当前支持的技能 URL 来源:", "urlExamples": "URL 示例:", "invalidSkillUrlSource": "技能 URL 当前需要以 https://skills.sh/、https://clawhub.ai/、https://skillsmp.com/、https://lobehub.com/、https://market.lobehub.com/、https://github.com/ 或 https://modelscope.cn/skills/ 开头", "source": "来源", "path": "路径", "skillDescription": "描述", "createSkill": "创建技能", "viewSkill": "查看技能", "editSkill": "编辑技能", "skillName": "技能名称", "skillContent": "技能内容", "pleaseInputName": "请输入技能名称", "pleaseInputContent": "请输入技能内容", "skillNamePlaceholder": "例如:weather_query", "contentPlaceholder": "【格式要求】\n---\nname: 技能名称(必填,英文小写下划线)\ndescription: 功能描述(必填,简洁清晰)\n---\n\n技能实现内容(Markdown格式)\n\n【示例】\n---\nname: weather_query\ndescription: 查询指定城市的天气信息\n---\n\n## 功能\n查询实时天气数据。\n\n## 使用\n用户输入城市名,返回天气信息。", "createSuccess": "技能创建成功", "createFailed": "技能创建失败", "deleteConfirm": "确定要删除此技能吗?", "deleteSuccess": "技能删除成功", "deleteFailed": "技能删除失败", "updateSuccess": "技能更新成功", "updateFailed": "技能更新失败", "frontmatterRequired": "Skills内容必须以 --- 开头和结尾", "frontmatterNameRequired": "Skills 中缺少必填字段:name", "frontmatterDescriptionRequired": "Skills 中缺少必填字段:description", "editNotSupported": "后端API不支持编辑操作", "editNote": "注意:后端API不支持编辑技能。您只能查看或切换启用/禁用状态。", "create": "创建", "optimizeWithAI": "AI优化", "stopOptimize": "停止", "optimizeSuccess": "技能优化成功", "optimizeFailed": "技能优化失败", "noContentToOptimize": "没有可优化的内容", "cancelImport": "取消导入", "importCancelled": "已取消技能导入", "importTimeout": "技能导入超时,请重试或检查网络。", "uploadSkill": "上传技能", "uploadSuccess": "技能上传成功", "uploadFailed": "技能上传失败", "uploadNoChange": "没有导入新技能,可能已存在相同技能", "zipOnly": "仅支持上传 .zip 文件", "fileSizeExceeded": "文件大小超过 100MB 限制。当前文件:{{size}}MB" }, "mcp": { "title": "MCP 客户端", "description": "管理模型上下文协议(MCP)客户端以扩展智能体能力。", "create": "创建客户端", "formatSupport": "支持的格式", "emptyState": "暂无配置的 MCP 客户端", "loadError": "加载 MCP 客户端失败", "createSuccess": "MCP 客户端创建成功", "createError": "MCP 客户端创建失败", "updateSuccess": "MCP 客户端更新成功", "updateError": "MCP 客户端更新失败", "enableSuccess": "MCP 客户端启用成功", "disableSuccess": "MCP 客户端禁用成功", "toggleError": "切换 MCP 客户端状态失败", "deleteConfirm": "确定要删除此 MCP 客户端吗?", "deleteSuccess": "MCP 客户端删除成功", "deleteError": "MCP 客户端删除失败" }, "heartbeat": { "title": "心跳", "description": "按固定间隔用 HEARTBEAT.md 内容执行自检。默认静默运行不影响当前对话,也可选择将回复发到上次对话频道。", "enabled": "开启心跳", "every": "执行间隔", "everyRequired": "请填写间隔", "everyMin": "间隔至少为 1", "unitMinutes": "分钟", "unitHours": "小时", "target": "回复目标", "targetMain": "静默运行(默认,不发送到频道)", "targetLast": "发到上次对话频道", "activeHours": "活跃时段(可选)", "activeStart": "开始时间", "activeEnd": "结束时间", "loadFailed": "加载心跳配置失败", "saveSuccess": "保存成功,心跳已热重载", "saveFailed": "保存心跳配置失败" }, "cronJobs": { "title": "定时任务", "description": "创建和管理在指定时间自动执行的定时任务。", "createJob": "创建任务", "editJob": "编辑任务", "confirmDelete": "确认删除", "deleteConfirm": "确定要删除此定时任务吗?", "okText": "确定", "cancelText": "取消", "deleteText": "删除", "id": "任务ID", "name": "任务名称", "enabled": "启用状态", "scheduleType": "调度类型", "scheduleCron": "执行时间(Cron)", "scheduleCronLabel": "执行时间(Cron)", "scheduleTimezone": "时区", "cronType": "频率类型", "cronTypeHourly": "每小时", "cronTypeDaily": "每天", "cronTypeWeekly": "每周", "cronTypeCustom": "自定义", "cronTime": "执行时间", "cronDaysOfWeek": "星期", "cronDayMon": "周一", "cronDayTue": "周二", "cronDayWed": "周三", "cronDayThu": "周四", "cronDayFri": "周五", "cronDaySat": "周六", "cronDaySun": "周日", "cronCustomExpression": "Cron 表达式", "taskText": "任务描述", "action": "操作", "disable": "禁用", "executeNow": "立即执行", "edit": "编辑", "delete": "删除", "totalItems": "共 {{count}} 项", "perPage": "/ 页", "pleaseInputId": "请输入任务ID", "pleaseInputName": "请输入任务名称", "pleaseInputCron": "请输入Cron表达式", "pleaseSelectTaskType": "请选择任务类型", "pleaseInputRequest": "请输入请求内容", "pleaseInputChannel": "请输入目标频道", "pleaseInputUserId": "请输入目标用户ID", "pleaseInputSessionId": "请输入目标会话ID", "jobIdPlaceholder": "例如:daily-report-job", "jobNamePlaceholder": "例如:每日早报", "selectTimezone": "选择时区", "taskDescriptionPlaceholder": "简要描述这个任务的作用...", "invalidJsonFormat": "JSON格式无效", "jsonFormatRequired": "需要JSON格式", "taskType": "任务类型", "text": "任务描述", "requestInput": "请求内容", "requestSessionId": "请求会话ID", "requestUserId": "请求用户ID", "dispatchChannel": "目标频道", "dispatchTargetUserId": "目标用户ID", "dispatchTargetSessionId": "目标会话ID", "dispatchMode": "分发模式", "runtimeMaxConcurrency": "最大并发数", "runtimeTimeoutSeconds": "超时时间(秒)", "runtimeMisfireGraceSeconds": "错过执行宽限期(秒)", "idTooltip": "任务的唯一标识符。建议使用小写字母、数字、连字符和下划线。", "nameTooltip": "任务的友好名称,便于识别。", "cronTooltip": "定义任务执行时间", "cronExample": "常用示例:'0 9 * * *' = 每天9点 | '*/30 * * * *' = 每30分钟 | '0 */2 * * *' = 每2小时 | '0 0 * * 0' = 每周日0点", "cronHelper": "不熟悉 Cron 表达式?", "cronHelperLink": "使用在线工具生成", "timezoneTooltip": "Cron 计划使用的时区。默认:UTC", "taskTypeTooltip": "选择 'text' 用于简单消息任务,选择 'agent' 用于复杂的智能体工作流。", "textTooltip": "可选:描述这个任务的作用,便于文档记录。", "requestInputTooltip": "JSON 格式的消息内容。这是智能体将接收和处理的内容。", "requestInputExample": "格式:[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"您的消息内容\"}]}]", "requestSessionIdTooltip": "请求上下文的会话ID。不确定时使用 'default'。", "requestUserIdTooltip": "发起请求的用户ID。自动化任务使用 'system'。", "dispatchChannelTooltip": "响应将发送到的目标频道(例如:'console'、'discord'、'imessage')。", "dispatchTargetUserIdTooltip": "在目标频道中接收响应的用户ID。", "dispatchTargetSessionIdTooltip": "在目标频道中传递响应的会话ID。", "dispatchModeTooltip": "选择 'stream' 获取实时响应,或选择 'final' 仅获取完整响应。", "maxConcurrencyTooltip": "此任务可以同时运行的最大数量。默认:1", "timeoutSecondsTooltip": "最大执行时间(秒)。超时将终止任务。", "misfireGraceSecondsTooltip": "错过执行的宽限期。如果任务错过计划时间超过此时长,将不会执行。", "executeNowTitle": "立即执行任务", "executeNowContent": "确定要立即执行任务 \"{{name}}\" 吗?", "executeNowConfirm": "立即执行" }, "channels": { "title": "频道", "description": "管理和配置消息频道", "loading": "正在加载频道...", "configSaved": "配置保存成功", "configFailed": "配置保存失败", "channelType": "频道类型", "status": "状态", "totalItems": "共 {{count}} 项", "botPrefix": "机器人前缀", "filterToolMessages": "显示工具消息", "filterToolMessagesTooltip": "向用户显示工具调用和输出消息(关闭则隐藏)", "filterThinking": "显示思考过程", "filterThinkingTooltip": "向用户显示模型的思考/推理内容(关闭则隐藏)", "notSet": "未设置", "clickCardToEdit": "点击卡片进行编辑", "settings": "设置", "channelSettings": "频道设置", "pleaseInputDbPath": "请输入数据库路径", "pleaseInputPollInterval": "请输入轮询间隔", "discordBotToken": "Discord机器人令牌", "httpProxyPlaceholder": "http://127.0.0.1:18118", "httpProxyAuthPlaceholder": "用户名:密码", "dbPathPlaceholder": "~/Library/Messages/chat.db", "botPrefixPlaceholder": "@bot", "dmPolicy": "私聊策略", "dmPolicyTooltip": "控制谁可以通过私聊与机器人交互。开放:所有人;白名单:仅限授权用户", "groupPolicy": "群聊策略", "groupPolicyTooltip": "控制谁可以在群聊中与机器人交互。开放:所有人;白名单:仅限授权用户", "requireMention": "需要 @提及", "requireMentionTooltip": "开启后,群聊中仅在被 @提及 时才会回复", "policyOpen": "开放", "policyAllowlist": "白名单", "allowFrom": "白名单用户", "allowFromTooltip": "允许与机器人交互的用户 ID 列表。启用白名单后,用户私聊机器人即可在拒绝消息中看到自己的 ID", "allowFromPlaceholder": "输入用户ID后按回车添加", "denyMessage": "拒绝访问消息", "denyMessageTooltip": "自定义消息,当用户被拒绝访问时显示", "twilioAccountSid": "Twilio 账户 SID", "twilioAuthToken": "Twilio 认证令牌", "phoneNumber": "电话号码", "phoneNumberSid": "电话号码 SID", "phoneNumberSidHelp": "可在 Twilio 控制台的 Phone Numbers → Active Numbers 中找到。", "ttsProvider": "TTS 提供商", "ttsVoice": "TTS 语音", "sttProvider": "STT 提供商", "language": "语言", "welcomeGreeting": "欢迎语", "welcomeText": "欢迎消息", "welcomeTextTooltip": "用户当天首次进入机器人单聊会话时机器人自动发送的消息", "welcomeTextPlaceholder": "例如:你好!我是 CoPaw,有什么可以帮你的?", "loginWeCom": "扫码授权企业微信机器人", "wecomAuthHint": "点击按钮后会弹出企业微信二维码窗口,扫码确认后 Bot ID 与 Secret 将自动填入。", "wecomAuthSuccess": "企业微信机器人授权成功", "wecomWindowBlocked": "弹窗被浏览器拦截,请允许弹窗后重试。", "wecomCancelled": "授权已取消", "wecomAuthFailed": "授权失败:{{msg}}", "wecomSdkLoadFailed": "WeCom SDK 加载失败", "voiceSetupGuide": "请先注册 Twilio 账户并购买电话号码,然后在下方填写凭据。Account SID 和 Auth Token 可在 Twilio 控制台首页找到。Phone Number SID 在 Phone Numbers → Active Numbers 中查看。", "voiceSetupLink": "打开 Twilio 控制台", "xiaoyiSetupGuide": "请在华为开发者平台创建智能体并获取 AK/SK 和 Agent ID。AK/SK 可在凭证管理页面找到。", "filterAll": "全部", "builtin": "内置", "custom": "自定义" }, "sessions": { "title": "会话", "description": "查看和管理活动聊天会话", "loading": "正在加载会话...", "confirmDelete": "确认删除", "deleteConfirm": "确定要删除此会话吗?", "batchDeleteConfirm": "确定要删除选中的 {{count}} 个会话吗?", "deleteSuccess": "会话删除成功", "deleteFailed": "会话删除失败", "batchDeleteButton": "批量删除", "filterUserId": "按用户ID筛选", "filterChannel": "按频道筛选", "allChannels": "全部频道", "totalItems": "共 {{count}} 项", "selectedItems": "已选 {{count}} 项", "editSession": "编辑会话", "pleaseInputName": "请输入会话名称", "sessionNamePlaceholder": "会话名称" }, "environments": { "title": "环境变量", "description": "为智能体和技能配置键值环境变量。", "key": "键", "value": "值", "variableNamePlaceholder": "VARIABLE_NAME", "valuePlaceholder": "值", "insertRowBelow": "在下方插入行", "deleteRow": "删除行", "deleteVariable": "删除变量", "deleteConfirm": "删除 \"{{name}}\"?", "deleteSelected": "删除选中项", "deleteSelectedConfirm": "删除 {{label}}?", "keyRequired": "键为必填项", "invalidKeyFormat": "键格式无效", "duplicateKey": "键重复", "saveSuccess": "环境变量已保存", "saveFailed": "保存失败", "deleteSuccess": "\"{{name}}\" 已删除", "deleteFailed": "删除失败", "noVariables": "尚未配置环境变量。", "addVariable": "添加变量", "loading": "加载中…", "retry": "重试", "of": "共", "selected": "已选", "variable": "变量", "variables": "变量", "showValue": "显示值", "hideValue": "隐藏值" }, "tokenUsage": { "title": "Token 消耗", "description": "查看一段时间内的 LLM Token 消耗,按日期和模型统计。", "totalTokens": "总 Token 数", "totalCalls": "总调用次数", "promptTokens": "输入 Token", "completionTokens": "输出 Token", "byModel": "按模型", "byDate": "按日期", "provider": "提供商", "model": "模型", "date": "日期", "refresh": "刷新", "loadFailed": "加载 Token 消耗数据失败", "noData": "所选时间段内暂无 Token 消耗数据" }, "modelSelector": { "selectModel": "选择模型", "noConfiguredModels": "暂无已配置模型", "switchFailed": "切换模型失败" }, "models": { "llmConfiguration": "LLM 配置", "providersTitle": "提供商", "providersDescription": "为每个提供商配置 API 密钥和端点。", "llmTitle": "LLM", "llmDescription": "从已授权的提供商中选择活动的 LLM 模型。", "configureProvider": "配置 {{name}}", "baseURL": "基础 URL", "apiKey": "API 密钥", "advancedConfig": "进阶配置", "generateConfig": "生成参数配置", "generateConfigHint": "使用 JSON 格式表示的生成参数配置项,会被展开传入到生成请求(`openai.chat.completions` 或 `anthropic.messages`)中。", "generateConfigInvalidJson": "请输入有效的 JSON", "generateConfigMustBeObject": "生成参数配置必须是 JSON 对象", "protocol": "协议", "protocolHint": "为当前配置选择提供商 API 协议。", "selectProtocol": "请选择协议", "protocolOpenAI": "OpenAI 兼容(Chat Completions)", "protocolAnthropic": "Anthropic(Messages API)", "currentKey": "当前密钥:{{key}}", "startsWith": "以 \"{{prefix}}\" 开头", "optionalSelfHosted": "自托管服务可选", "leaveBlankKeep": "留空以保持当前密钥", "enterApiKey": "输入 API 密钥 ({{prefix}}...)", "enterApiKeyOptional": "输入 API 密钥(可选)", "openAIEndpoint": "OpenAI 端点,例如 https://api.openai.com/v1", "openAICompatibleEndpoint": "OpenAI 兼容端点,例如 https://api.example.com(仅在你的服务要求时再追加 /v1)", "azureEndpointHint": "Azure OpenAI 端点,例如 https://.openai.azure.com/openai/v1", "anthropicEndpointHint": "Anthropic 端点,例如 https://api.anthropic.com", "ollamaEndpointHint": "Ollama 端点,例如 http://localhost:11434", "lmstudioEndpointHint": "LM Studio 端点,例如 http://localhost:1234/v1", "apiEndpointHint": "API 端点,例如 https://api.example.com", "pleaseEnterBaseURL": "请输入 API 基础 URL", "pleaseEnterValidURL": "请输入有效的 URL", "apiKeyShouldStart": "API 密钥应以 \"{{prefix}}\" 开头", "configurationSaved": "{{name}} 配置已保存", "failedToSaveConfig": "保存配置失败", "revokeAuthorization": "撤销授权", "revokeConfirmContent": "确定要移除 {{name}} 的 API 密钥吗?当前 LLM 模型配置也将被清除。", "revokeConfirmSimple": "确定要移除 {{name}} 的 API 密钥吗?", "authorizationRevoked": "{{name}} 授权已撤销,LLM 模型已清除", "authorizationRevokedSimple": "{{name}} 授权已撤销", "failedToRevoke": "撤销授权失败", "active": "活动:{{provider}} / {{model}}", "provider": "提供商", "model": "模型", "selectProvider": "选择提供商(必须已授权)", "selectModel": "选择模型", "llmModelUpdated": "LLM 模型已更新", "failedToSave": "保存失败", "saved": "已保存", "save": "保存", "cancel": "取消", "loading": "加载中...", "retry": "重试", "notSet": "未设置", "available": "可用", "unavailable": "不可用", "providerAvailable": "可用(有模型)", "providerNoModels": "未就绪(无模型)", "providerNotConfigured": "未就绪(未配置)", "settings": "设置", "actions": "操作", "custom": "自定义", "builtin": "内置", "addProvider": "添加提供商", "addProviderTitle": "添加自定义提供商", "providerIdLabel": "提供商 ID", "providerIdPlaceholder": "例如 openai, google, anthropic", "providerIdHint": "小写字母、数字、连字符、下划线,创建后不可更改。", "providerNameLabel": "显示名称", "providerNamePlaceholder": "例如 OpenAI, Google Gemini", "defaultBaseUrlLabel": "默认 Base URL", "defaultBaseUrlPlaceholder": "例如 https://api.example.com", "apiKeyPrefixLabel": "API Key 前缀(可选)", "apiKeyPrefixPlaceholder": "例如 sk-", "providerCreated": "提供商 \"{{name}}\" 已创建", "providerCreateFailed": "创建提供商失败", "deleteProvider": "删除提供商", "deleteProviderConfirm": "确定删除自定义提供商 \"{{name}}\" 及其所有模型?此操作不可撤销。", "providerDeleted": "提供商 \"{{name}}\" 已删除", "providerDeleteFailed": "删除提供商失败", "manageModels": "模型", "manageModelsTitle": "{{provider}} — 模型管理", "userAdded": "用户添加", "addModel": "添加模型", "addModelTitle": "为 {{provider}} 添加模型", "modelIdLabel": "模型 ID", "modelIdPlaceholder": "例如 gpt-4o, gemini-2.0-flash", "modelNameLabel": "模型名称", "modelNameRequired": "请输入模型名称", "modelNamePlaceholder": "例如 GPT-4o, Gemini 2.0 Flash", "modelAdded": "模型 \"{{name}}\" 已添加", "modelAddFailed": "添加模型失败", "removeModel": "移除", "removeModelConfirm": "确定从 {{provider}} 中移除模型 \"{{name}}\"?", "modelRemoved": "模型 \"{{name}}\" 已移除", "modelRemoveFailed": "移除模型失败", "modelsCount": "{{count}} 个模型", "noModels": "暂无模型", "addModelFirst": "请先添加模型", "local": "本地", "localType": "类型", "localEmbedded": "嵌入式(进程内)", "localDownloadFirst": "请先下载模型", "localDownloadModel": "下载模型", "localModelsTitle": "{{provider}} — 本地模型", "localNoModels": "暂无已下载的模型", "localRepoId": "仓库 ID", "localRepoIdRequired": "请输入仓库 ID", "localRepoIdPlaceholder": "例如 TheBloke/Mistral-7B-GGUF", "localFilename": "文件名(可选)", "localFilenamePlaceholder": "例如 mistral-7b.Q4_K_M.gguf", "localFilenameHint": "留空将自动选择最佳量化版本", "localSource": "来源", "localDownloadSuccess": "模型下载成功", "localDownloadFailed": "模型下载失败", "localDeleteModel": "删除模型", "localDeleteConfirm": "确定删除本地模型 \"{{name}}\"?模型文件将从磁盘中删除。", "localModelDeleted": "模型 \"{{name}}\" 已删除", "localDeleteFailed": "删除模型失败", "localDownloadPending": "准备下载...", "localDownloading": "正在下载 {{repo}}... 可能需要几分钟。", "localDownloadNavigateHint": "您可以离开此页面,下载将在后台继续进行。", "localDownloadInProgress": "已有下载任务正在进行", "localCancelDownload": "取消下载", "localCancelDownloadConfirm": "确定取消下载 \"{{repo}}\"?", "localDownloadCancelled": "下载已取消", "localCancelDownloadFailed": "取消下载失败", "ollamaModelNamePlaceholder": "例如 mistral:7b, qwen3:8b", "testConnection": "测试连接", "testConnectionSuccess": "连接测试成功", "testConnectionFailed": "连接测试失败", "testConnectionError": "测试连接时发生错误", "discoverModels": "自动获取模型", "discoverModelsFailed": "自动获取模型失败", "ollamaFetchModelsFailed": "获取 Ollama 模型列表失败,请查看错误详情。", "modelTestFailed": "模型验证失败,请检查模型ID是否正确", "modelTestFailedConfirm": "模型连接测试失败:{{message}}。是否仍要添加此模型?", "autoDiscoveredAndAdded": "已自动获取 {{count}} 个模型,并新增 {{added}} 个到可选列表", "autoDiscoveredNoNew": "已自动获取 {{count}} 个模型,当前列表已是最新", "autoDiscoverFailed": "自动获取模型失败,可在模型管理中手动添加" }, "agentConfig": { "title": "运行配置", "description": "配置智能体运行参数", "reactAgentTitle": "ReAct 智能体", "contextManagementTitle": "上下文管理", "maxIters": "最大迭代次数", "maxItersTooltip": "ReAct 智能体的最大推理-行动迭代次数", "maxItersPlaceholder": "请输入最大迭代次数", "maxItersRequired": "最大迭代次数为必填项", "maxItersMin": "最大迭代次数必须大于等于 1", "maxInputLength": "最大输入长度", "maxInputLengthTooltip": "模型上下文窗口的最大输入长度(token 数)", "maxInputLengthPlaceholder": "请输入最大输入长度", "maxInputLengthRequired": "最大输入长度为必填项", "maxInputLengthMin": "最大输入长度必须大于等于 1000", "contextCompactRatio": "上下文压缩比例", "contextCompactRatioTooltip": "当上下文满时,压缩上下文的比例", "contextCompactRatioRequired": "上下文压缩比例为必填项", "contextCompactThreshold": "上下文压缩阈值", "contextCompactThresholdTooltip": "上下文压缩阈值(token 数),根据最大输入长度和上下文压缩比例自动计算", "contextCompactThresholdPlaceholder": "自动计算", "contextCompactReserveRatio": "上下文保留比例", "contextCompactReserveRatioTooltip": "压缩上下文时保留的比例", "contextCompactReserveRatioRequired": "上下文保留比例为必填项", "contextCompactReserveThreshold": "上下文保留阈值", "contextCompactReserveThresholdTooltip": "上下文保留阈值(token 数),根据最大输入长度和上下文保留比例自动计算", "contextCompactReserveThresholdPlaceholder": "自动计算", "toolResultCompactRecentN": "工具调用结果recent_n", "toolResultCompactRecentNTooltip": "使用最近消息阈值的工具调用结果数量", "toolResultCompactRecentNRequired": "工具调用结果recent_n为必填项", "toolResultCompactOldThreshold": "超出recent_n的工具调用结果的最大字符数", "toolResultCompactOldThresholdTooltip": "超出recent_n范围的工具调用结果,超过此字符数将被压缩", "toolResultCompactOldThresholdPlaceholder": "请输入字符阈值", "toolResultCompactOldThresholdRequired": "超出recent_n的工具调用结果最大字符数为必填项", "toolResultCompactRecentThreshold": "recent_n内的工具调用结果的最大字符数", "toolResultCompactRecentThresholdTooltip": "recent_n范围内的工具调用结果,超过此字符数将被压缩", "toolResultCompactRecentThresholdPlaceholder": "请输入字符阈值", "toolResultCompactRecentThresholdRequired": "recent_n内的工具调用结果最大字符数为必填项", "toolResultCompactRetentionDays": "工具调用的文件保留天数", "toolResultCompactRetentionDaysTooltip": "压缩后的工具结果文件保留天数,过期自动清理", "toolResultCompactRetentionDaysRequired": "工具调用的文件保留天数为必填项", "language": "智能体语言", "languageTooltip": "智能体人设文件(SOUL.md、AGENTS.md 等)使用的语言。切换语言后将自动重新复制对应语言的 MD 文件。", "languageConfirmTitle": "切换智能体语言", "languageConfirmContent": "切换语言将会覆盖以下文件为新语言的默认版本:\n\n SOUL.md、AGENTS.md、PROFILE.md、MEMORY.md、BOOTSTRAP.md、HEARTBEAT.md\n\n如果您已对这些文件进行过自定义修改,请提前备份。您自行添加的其他文件不受影响。", "languageConfirmOk": "切换语言", "languageSaveSuccess": "语言更新成功", "languageSaveSuccessWithFiles": "语言已更新,已复制 {{count}} 个 MD 文件", "languageSaveFailed": "语言更新失败", "timezone": "用户时区", "timezoneTooltip": "用于定时任务、时间显示和 Agent 上下文,默认使用系统时区。", "timezoneRequired": "请选择时区", "selectTimezone": "选择时区", "timezoneSaveSuccess": "时区更新成功", "timezoneSaveFailed": "时区更新失败", "saveSuccess": "配置保存成功", "saveFailed": "配置保存失败", "loadFailed": "配置加载失败" }, "tools": { "title": "内置工具", "description": "管理内置工具及其启用状态。禁用的工具将不会提供给智能体使用。", "emptyState": "暂无工具配置", "loadError": "加载工具失败", "enableSuccess": "工具启用成功", "disableSuccess": "工具禁用成功", "toggleError": "切换工具状态失败", "enableAll": "全部启用", "disableAll": "全部禁用", "enableAllSuccess": "全部工具已启用", "disableAllSuccess": "全部工具已禁用", "allEnabled": "所有工具已处于启用状态", "allDisabled": "所有工具已处于禁用状态" }, "modelConfig": { "promptTitle": "需要配置 LLM 模型,配置模型后在 Chat 页面选择", "promptMessage": "聊天功能需要 LLM 模型才能运行。请先配置模型,配置完成后在 Chat 页面顶部的模型选择器中选择即可使用。", "configureButton": "配置模型", "skipButton": "跳过", "chatDisabledTitle": "聊天已禁用", "chatDisabledMessage": "聊天功能已禁用,因为未配置模型。请配置模型以启用聊天功能。", "configureNow": "配置模型", "modelNotConfigured": "请先在设置中配置模型后再使用聊天功能" }, "header": { "changelog": "更新日志", "docs": "文档", "tutorial": "教程", "faq": "常见问题", "github": "GitHub" }, "theme": { "dark": "深色", "light": "浅色", "darkMode": "深色模式", "lightMode": "浅色模式", "switchToDark": "切换到深色模式", "switchToLight": "切换到浅色模式" }, "sidebar": { "newVersion": "发现新版本 v{{version}},点击前往升级", "updateModal": { "title": "发现新版本 v{{version}}", "viewReleases": "查看 Releases", "close": "关闭" } }, "chat": { "disclaimer": "懂你所需,伴你左右", "greeting": "你好,我今天能帮你做什么?", "description": "我是一个智能助手,可以帮助你解答问题。", "prompt1": "让我们开启一段新的旅程吧!", "prompt2": "能告诉我你有哪些技能吗?", "attachments": { "tooltip": "支持文档、图片、视频、音频等多种格式,单文件不超过10MB", "fileSizeLimit": "单文件不超过10MB" } }, "security": { "title": "安全", "description": "管理工具和技能的安全功能。", "toolGuardTitle": "工具防护", "toolGuardDescription": "配置工具调用的安全扫描。危险操作将在执行前需要你的明确批准。", "enabled": "启用工具防护", "enabledTooltip": "启用后,工具调用在执行前会被扫描是否包含危险模式", "guardedTools": "受保护的工具", "guardedToolsTooltip": "检测到危险模式时需要审批的工具。留空则使用内置默认集合。", "guardedToolsPlaceholder": "选择工具或输入自定义工具名", "deniedTools": "禁止的工具", "deniedToolsTooltip": "始终被拒绝的工具,无需审批直接拦截", "deniedToolsPlaceholder": "选择要始终禁止的工具", "saveSuccess": "工具防护设置已保存", "saveFailed": "保存工具防护设置失败", "loadFailed": "加载工具防护设置失败", "rules": { "title": "检测规则", "description": "规则定义了触发安全警告的正则表达式模式。内置规则可以禁用;自定义规则可以添加、编辑或删除。", "id": "规则 ID", "severity": "严重程度", "descriptionCol": "描述", "source": "来源", "actions": "操作", "builtin": "内置", "custom": "自定义", "enable": "启用", "disable": "禁用", "edit": "编辑", "delete": "删除", "add": "添加规则", "addTitle": "添加自定义规则", "editTitle": "编辑自定义规则", "ruleId": "规则 ID", "ruleIdRequired": "规则 ID 不能为空", "duplicateId": "已存在相同 ID 的规则", "tools": "目标工具", "toolsPlaceholder": "留空匹配所有工具", "params": "目标参数", "paramsPlaceholder": "留空匹配所有参数", "severityLabel": "严重程度", "categoryLabel": "分类", "patterns": "正则模式", "patternsRequired": "至少需要一个正则模式", "patternsTooltip": "每行一个正则表达式,匹配工具参数值(不区分大小写)。", "excludePatterns": "排除模式", "excludePatternsTooltip": "每行一个正则表达式。如果匹配,则跳过该规则。", "descriptionLabel": "描述", "descriptionPlaceholder": "该规则检测什么?", "remediationLabel": "修复建议", "remediationPlaceholder": "触发规则时建议的操作", "preview": "预览", "previewTitle": "规则详情", "actionLabel": "触发行为", "actionApproval": "等待审批", "allTools": "所有工具", "allParams": "所有参数", "descriptions": { "TOOL_CMD_DANGEROUS_RM": "检测可能导致数据丢失的 rm 命令", "TOOL_CMD_DANGEROUS_MV": "检测可能意外移动或覆盖文件的 mv 命令", "TOOL_CMD_FS_DESTRUCTION": "检测低级别磁盘格式化或擦除命令", "TOOL_CMD_DOS_FORK_BOMB": "检测经典 Bash Fork 炸弹和批量进程终止", "TOOL_CMD_PIPE_TO_SHELL": "检测通过 'curl | bash' 模式下载并立即执行远程载荷的行为", "TOOL_CMD_REVERSE_SHELL": "检测建立反向 Shell 或未授权网络隧道的行为", "TOOL_CMD_SYSTEM_TAMPERING": "检测对定时任务、SSH 密钥或 sudo 权限的访问(包括读取和修改)", "TOOL_CMD_UNSAFE_PERMISSIONS": "检测全局权限降级(chmod 777)或设置不可变标志的操作", "TOOL_CMD_OBFUSCATED_EXEC": "检测将 base64 编码字符串直接传递给 Shell 解释器执行的行为" } }, "skillScanner": { "title": "技能扫描器", "description": "在启用或安装技能前,自动扫描安全威胁。不安全的技能可以被拦截或加入白名单。", "mode": "扫描模式", "modeTooltip": "控制扫描器如何处理不安全的技能:拦截、仅提醒或关闭", "modeBlock": "拦截", "modeWarn": "仅提醒", "modeOff": "关闭", "timeout": "扫描超时(秒)", "timeoutTooltip": "等待扫描完成的最长时间(5-300秒)", "saveSuccess": "技能扫描器设置已保存", "saveFailed": "保存技能扫描器设置失败", "scanAlerts": { "title": "扫描告警", "empty": "暂无安全告警", "clearAll": "清除全部", "clearConfirm": "确定清除所有扫描告警吗?", "skillName": "技能", "action": "动作", "actionBlocked": "已拦截", "actionWarned": "已提醒", "time": "时间", "actions": "操作", "allowSkill": "加入白名单", "remove": "删除", "viewFindings": "查看详情" }, "whitelist": { "title": "白名单", "empty": "暂无白名单技能", "skillName": "技能", "addedAt": "添加时间", "contentHash": "内容哈希", "actions": "操作", "remove": "移除", "removeConfirm": "确定将此技能从白名单中移除?", "addSuccess": "技能已加入白名单", "removeSuccess": "技能已从白名单移除", "addFailed": "加入白名单失败", "removeFailed": "从白名单移除失败", "removeWillDisable": "移除后该技能将同时被禁用。", "removeAndDisabled": "技能已从白名单移除并已禁用" }, "scanError": { "title": "检测到安全问题", "description": "发现以下安全问题:", "warnDescription": "发现安全问题,但技能仍已启用(仅提醒模式):", "goToWhitelist": "前往白名单" } } }, "login": { "title": "登录 CoPaw", "registerTitle": "创建账户", "firstUserHint": "创建管理员账户以开始使用", "usernamePlaceholder": "用户名", "passwordPlaceholder": "密码", "usernameRequired": "请输入用户名", "passwordRequired": "请输入密码", "submit": "登录", "register": "注册", "failed": "登录失败,请检查您的凭据", "registerSuccess": "注册成功", "registerFailed": "注册失败", "authNotEnabled": "认证未启用", "logout": "退出登录", "logoutSuccess": "已成功退出", "sessionExpired": "会话已过期,请重新登录", "unauthorized": "未授权,请登录" }, "voiceTranscription": { "title": "语音转写", "description": "配置如何处理收到的音频和语音消息。", "loadFailed": "加载音频模式设置失败", "saveSuccess": "音频模式已保存", "saveFailed": "保存音频模式失败", "audioModeLabel": "音频模式", "audioModeDescription": "选择来自频道(Discord、Telegram 等)的语音消息在发送给模型之前如何处理。", "modeAuto": "自动(推荐)", "modeAutoDesc": "使用所选转写提供商将音频转写为文字后发送给模型。如果转写未启用或不可用,则显示文件上传占位消息。此模式下音频不会直接发送给模型。适用于所有模型。", "modeNative": "原生音频", "modeNativeDesc": "直接将音频文件发送给模型,不进行转写。这是唯一会将音频发送给模型的模式。仅适用于特定的音频模型(如 gpt-4o-audio),大多数模型不支持此模式。", "ffmpegReady": "ffmpeg 已安装。原生模式音频格式转换可用。", "ffmpegMissing": "ffmpeg 未安装。", "ffmpegMissingDesc": "原生音频模式需要 ffmpeg 来转换音频格式(如 .ogg 转 .wav)。请安装 ffmpeg 系统包以启用此模式。", "providerTypeLabel": "转写提供商", "providerTypeDescription": "选择转写后端。如果不需要语音转写,请选择「已禁用」。", "providerTypeDisabled": "已禁用", "providerTypeDisabledDesc": "不进行转写。语音消息将显示为文件上传占位消息。", "providerTypeWhisperApi": "Whisper API", "providerTypeWhisperApiDesc": "使用已配置提供商(如 OpenAI、Ollama)的 OpenAI 兼容 Whisper API 端点。", "providerTypeLocalWhisper": "本地 Whisper", "providerTypeLocalWhisperDesc": "使用本地安装的 openai-whisper Python 库进行转写。需要同时安装 ffmpeg 和 openai-whisper。", "localWhisperReady": "本地 Whisper 已就绪。ffmpeg 和 openai-whisper 均已安装。", "localWhisperMissing": "本地 Whisper 未就绪,缺少必要依赖。", "localWhisperMissingDesc": "ffmpeg: {{ffmpeg}} | openai-whisper: {{whisper}}。请安装缺少的依赖:ffmpeg(系统包)和 openai-whisper(uv pip install openai-whisper,或使用 [whisper] 额外依赖安装 CoPaw)。", "providerLabel": "Whisper API 提供商", "providerDescription": "选择用于 Whisper API 音频转写的提供商。仅显示支持 Whisper 端点的提供商。", "providerPlaceholder": "选择提供商...", "noProvidersWarning": "未找到支持转写的提供商。请配置一个 OpenAI 提供商以启用语音转写。", "transcriptionInfoTitle": "转写工作原理", "transcriptionInfoDesc": "Whisper API 转写使用 OpenAI 兼容的 /v1/audio/transcriptions 端点。需要配置一个支持 Whisper 端点的提供商(如 OpenAI)。请在上方选择具体的提供商以启用转写。", "transcriptionInfoDescLocal": "本地 Whisper 转写直接在您的设备上运行 openai-whisper 库。需要同时安装 ffmpeg(用于音频解码)和 openai-whisper Python 包。无需 API Key 或网络连接。安装命令:uv pip install 'copaw[whisper]'。" } } ================================================ FILE: console/src/main.tsx ================================================ import { createRoot } from "react-dom/client"; import App from "./App.tsx"; import "./i18n"; if (typeof window !== "undefined") { const originalError = console.error; const originalWarn = console.warn; console.error = function (...args: any[]) { const msg = args[0]?.toString() || ""; if (msg.includes(":first-child") || msg.includes("pseudo class")) { return; } originalError.apply(console, args); }; console.warn = function (...args: any[]) { const msg = args[0]?.toString() || ""; if ( msg.includes(":first-child") || msg.includes("pseudo class") || msg.includes("potentially unsafe") ) { return; } originalWarn.apply(console, args); }; } createRoot(document.getElementById("root")!).render(); ================================================ FILE: console/src/pages/Agent/Config/components/ContextManagementCard.tsx ================================================ import { Form, InputNumber, Input, Card } from "@agentscope-ai/design"; import { useTranslation } from "react-i18next"; import { SliderWithValue } from "./SliderWithValue"; import styles from "../index.module.less"; interface ContextManagementCardProps { contextCompactThreshold: number; contextCompactReserveThreshold: number; } export function ContextManagementCard({ contextCompactThreshold, contextCompactReserveThreshold, }: ContextManagementCardProps) { const { t } = useTranslation(); return ( 0 ? contextCompactThreshold.toLocaleString() : "" } placeholder={t("agentConfig.contextCompactThresholdPlaceholder")} /> 0 ? contextCompactReserveThreshold.toLocaleString() : "" } placeholder={t( "agentConfig.contextCompactReserveThresholdPlaceholder", )} /> ); } ================================================ FILE: console/src/pages/Agent/Config/components/PageHeader.tsx ================================================ import { useTranslation } from "react-i18next"; import styles from "../index.module.less"; export function PageHeader() { const { t } = useTranslation(); return (

{t("agentConfig.title")}

{t("agentConfig.description")}

); } ================================================ FILE: console/src/pages/Agent/Config/components/ReactAgentCard.tsx ================================================ import { Form, InputNumber, Select, Card } from "@agentscope-ai/design"; import { useTranslation } from "react-i18next"; import { TIMEZONE_OPTIONS } from "../../../../constants/timezone"; import styles from "../index.module.less"; const LANGUAGE_OPTIONS = [ { value: "zh", label: "中文" }, { value: "en", label: "English" }, { value: "ru", label: "Русский" }, ]; interface ReactAgentCardProps { language: string; savingLang: boolean; onLanguageChange: (value: string) => void; timezone: string; savingTimezone: boolean; onTimezoneChange: (value: string) => void; } export function ReactAgentCard({ language, savingLang, onLanguageChange, timezone, savingTimezone, onTimezoneChange, }: ReactAgentCardProps) { const { t } = useTranslation(); return ( (option?.label?.toString() || "") .toLowerCase() .includes(input.toLowerCase()) } options={TIMEZONE_OPTIONS} onChange={onTimezoneChange} loading={savingTimezone} disabled={savingTimezone} style={{ width: "100%" }} /> ); } ================================================ FILE: console/src/pages/Agent/Config/components/SliderWithValue.tsx ================================================ import { Slider } from "@agentscope-ai/design"; interface SliderWithValueProps { value?: number; min?: number; max?: number; step?: number; marks?: Record; onChange?: (value: number) => void; } export function SliderWithValue({ value, min, max, step, marks, onChange, }: SliderWithValueProps) { const formatValue = (v: number) => { if (v >= 1) return v.toString(); return v.toFixed(2); }; return (
{value !== undefined ? formatValue(value) : "-"}
); } ================================================ FILE: console/src/pages/Agent/Config/components/index.ts ================================================ export { SliderWithValue } from "./SliderWithValue"; export { PageHeader } from "./PageHeader"; export { ReactAgentCard } from "./ReactAgentCard"; export { ContextManagementCard } from "./ContextManagementCard"; ================================================ FILE: console/src/pages/Agent/Config/index.module.less ================================================ .configPage { padding: 24px; height: 100%; overflow-y: auto; width: 100%; } .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; } .headerActions { display: flex; gap: 8px; flex-shrink: 0; margin-top: 34px; } .title { font-size: 24px; font-weight: 600; margin: 0 0 8px 0; color: #000; } .description { font-size: 14px; color: #666; margin: 0; } .formCard { width: 100%; &:hover { border: 1px solid #615ced; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.08); } } .form { :global { .ant-form-item-label > label { font-weight: 500; } } } .buttonGroup { display: flex; gap: 12px; justify-content: flex-end; margin-top: 32px; margin-bottom: 0; } .centerState { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 400px; gap: 16px; } .stateText { font-size: 14px; color: #666; } .stateTextError { font-size: 14px; color: #ff4d4f; } .footerActions { display: flex; justify-content: flex-end; align-items: center; padding: 16px 24px; } ================================================ FILE: console/src/pages/Agent/Config/index.tsx ================================================ import { useState } from "react"; import { Button, Form } from "@agentscope-ai/design"; import { useTranslation } from "react-i18next"; import { useAgentConfig } from "./useAgentConfig.tsx"; import { PageHeader, ReactAgentCard, ContextManagementCard, } from "./components"; import styles from "./index.module.less"; function AgentConfigPage() { const { t } = useTranslation(); const { form, loading, saving, error, language, savingLang, timezone, savingTimezone, fetchConfig, handleSave, handleLanguageChange, handleTimezoneChange, } = useAgentConfig(); // Force re-render when form values change to refresh derived threshold values const [, forceUpdate] = useState({}); const handleValuesChange = () => forceUpdate({}); const getCalculatedValues = () => { const values = form.getFieldsValue([ "max_input_length", "memory_compact_ratio", "memory_reserve_ratio", ]); const maxInputLength = values.max_input_length ?? 0; const memoryCompactRatio = values.memory_compact_ratio ?? 0; const memoryReserveRatio = values.memory_reserve_ratio ?? 0; return { contextCompactThreshold: Math.floor(maxInputLength * memoryCompactRatio), contextCompactReserveThreshold: Math.floor( maxInputLength * memoryReserveRatio, ), }; }; const { contextCompactThreshold, contextCompactReserveThreshold } = getCalculatedValues(); if (loading) { return (
{t("common.loading")}
); } if (error) { return (
{error}
); } return (
); } export default AgentConfigPage; ================================================ FILE: console/src/pages/Agent/Config/useAgentConfig.tsx ================================================ import { useState, useEffect, useCallback } from "react"; import { Form, Modal, message } from "@agentscope-ai/design"; import { useTranslation } from "react-i18next"; import api from "../../../api"; import type { AgentsRunningConfig } from "../../../api/types"; export function useAgentConfig() { const { t } = useTranslation(); const [form] = Form.useForm(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [language, setLanguage] = useState("zh"); const [savingLang, setSavingLang] = useState(false); const [timezone, setTimezone] = useState("UTC"); const [savingTimezone, setSavingTimezone] = useState(false); const fetchConfig = useCallback(async () => { setLoading(true); setError(null); try { const [config, langResp, tzResp] = await Promise.all([ api.getAgentRunningConfig(), api.getAgentLanguage(), api.getUserTimezone(), ]); form.setFieldsValue(config); setLanguage(langResp.language); setTimezone(tzResp.timezone || "UTC"); } catch (err) { const errMsg = err instanceof Error ? err.message : t("agentConfig.loadFailed"); setError(errMsg); } finally { setLoading(false); } }, [form, t]); useEffect(() => { fetchConfig(); }, [fetchConfig]); const handleSave = useCallback(async () => { try { const values = await form.validateFields(); setSaving(true); await api.updateAgentRunningConfig(values as AgentsRunningConfig); message.success(t("agentConfig.saveSuccess")); } catch (err) { if (err instanceof Error && "errorFields" in err) return; const errMsg = err instanceof Error ? err.message : t("agentConfig.saveFailed"); message.error(errMsg); } finally { setSaving(false); } }, [form, t]); const handleLanguageChange = useCallback( (value: string): void => { if (value === language) return; Modal.confirm({ title: t("agentConfig.languageConfirmTitle"), content: ( {t("agentConfig.languageConfirmContent")} ), okText: t("agentConfig.languageConfirmOk"), cancelText: t("common.cancel"), onOk: async () => { setSavingLang(true); try { const resp = await api.updateAgentLanguage(value); setLanguage(resp.language); if (resp.copied_files && resp.copied_files.length > 0) { message.success( t("agentConfig.languageSaveSuccessWithFiles", { count: resp.copied_files.length, }), ); } else { message.success(t("agentConfig.languageSaveSuccess")); } } catch (err) { const errMsg = err instanceof Error ? err.message : t("agentConfig.languageSaveFailed"); message.error(errMsg); } finally { setSavingLang(false); } }, }); }, [language, t], ); const handleTimezoneChange = useCallback( async (value: string) => { if (value === timezone) return; setSavingTimezone(true); try { await api.updateUserTimezone(value); setTimezone(value); message.success(t("agentConfig.timezoneSaveSuccess")); } catch (err) { const errMsg = err instanceof Error ? err.message : t("agentConfig.timezoneSaveFailed"); message.error(errMsg); } finally { setSavingTimezone(false); } }, [timezone, t], ); return { form, loading, saving, error, language, savingLang, timezone, savingTimezone, fetchConfig, handleSave, handleLanguageChange, handleTimezoneChange, }; } ================================================ FILE: console/src/pages/Agent/MCP/components/MCPClientCard.tsx ================================================ import { Card, Button, Modal, Tooltip, Input } from "@agentscope-ai/design"; import { DeleteOutlined } from "@ant-design/icons"; import { Server } from "lucide-react"; import type { MCPClientInfo } from "../../../../api/types"; import { useTranslation } from "react-i18next"; import { useState } from "react"; import { useTheme } from "../../../../contexts/ThemeContext"; import styles from "../index.module.less"; interface MCPClientCardProps { client: MCPClientInfo; onToggle: (client: MCPClientInfo, e: React.MouseEvent) => void; onDelete: (client: MCPClientInfo, e: React.MouseEvent) => void; onUpdate: (key: string, updates: any) => Promise; isHovered: boolean; onMouseEnter: () => void; onMouseLeave: () => void; } export function MCPClientCard({ client, onToggle, onDelete, onUpdate, isHovered, onMouseEnter, onMouseLeave, }: MCPClientCardProps) { const { t } = useTranslation(); const { isDark } = useTheme(); const [jsonModalOpen, setJsonModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [editedJson, setEditedJson] = useState(""); const [isEditing, setIsEditing] = useState(false); // Determine if MCP client is remote or local based on command const isRemote = client.transport === "streamable_http" || client.transport === "sse"; const clientType = isRemote ? "Remote" : "Local"; const handleToggleClick = (e: React.MouseEvent) => { e.stopPropagation(); onToggle(client, e); }; const handleDeleteClick = (e: React.MouseEvent) => { e.stopPropagation(); setDeleteModalOpen(true); }; const confirmDelete = () => { setDeleteModalOpen(false); onDelete(client, null as any); }; const handleCardClick = () => { const jsonStr = JSON.stringify(client, null, 2); setEditedJson(jsonStr); setIsEditing(false); setJsonModalOpen(true); }; const handleSaveJson = async () => { try { const parsed = JSON.parse(editedJson); const { key, ...updates } = parsed; // Send all updates directly to backend, let backend handle env masking check const success = await onUpdate(client.key, updates); if (success) { setJsonModalOpen(false); setIsEditing(false); } } catch (error) { alert("Invalid JSON format"); } }; const clientJson = JSON.stringify(client, null, 2); return ( <>

{client.name}

{clientType}
{client.enabled ? t("common.enabled") : t("common.disabled")}
{client.description || "\u00A0"}
setDeleteModalOpen(false)} okText={t("common.confirm")} cancelText={t("common.cancel")} okButtonProps={{ danger: true }} >

{t("mcp.deleteConfirm")}

setJsonModalOpen(false)} footer={
{isEditing ? ( ) : ( )}
} width={700} > {isEditing ? ( setEditedJson(e.target.value)} autoSize={{ minRows: 15, maxRows: 25 }} style={{ fontFamily: "Monaco, Courier New, monospace", fontSize: 13, }} /> ) : (
            {clientJson}
          
)}
); } ================================================ FILE: console/src/pages/Agent/MCP/components/MCPClientDrawer.tsx ================================================ import { Drawer, Form, Input, Switch, Button } from "@agentscope-ai/design"; import type { MCPClientInfo } from "../../../../api/types"; import { useTranslation } from "react-i18next"; import { useState } from "react"; interface MCPClientDrawerProps { open: boolean; client: MCPClientInfo | null; onClose: () => void; onSubmit: ( key: string, values: { name: string; command?: string; enabled?: boolean; transport?: "stdio" | "streamable_http" | "sse"; url?: string; headers?: Record; args?: string[]; env?: Record; cwd?: string; }, ) => Promise; form: any; } export function MCPClientDrawer({ open, client, onClose, onSubmit, form, }: MCPClientDrawerProps) { const { t } = useTranslation(); const [submitting, setSubmitting] = useState(false); const isEditing = !!client; const handleSubmit = async () => { try { const values = await form.validateFields(); setSubmitting(true); const clientData = { name: values.name, command: values.command, enabled: values.enabled ?? true, args: values.args ? values.args.split(" ").filter(Boolean) : [], env: values.env ? JSON.parse(values.env) : {}, }; const key = isEditing ? client.key : values.key; const success = await onSubmit(key, clientData); if (success) { onClose(); } } catch (error) { console.error("Form validation failed:", error); } finally { setSubmitting(false); } }; return ( } >
{!isEditing && ( )}
); } ================================================ FILE: console/src/pages/Agent/MCP/components/index.ts ================================================ export { MCPClientCard } from "./MCPClientCard"; export { MCPClientDrawer } from "./MCPClientDrawer"; ================================================ FILE: console/src/pages/Agent/MCP/index.module.less ================================================ .mcpCard { border-radius: 12px; transition: all 0.2s ease-in-out; cursor: pointer; padding: 16px; &.enabledCard { border: 2px solid #615ced !important; box-shadow: rgba(97, 92, 237, 0.2) 0px 4px 12px !important; } &.hover { border: 1px solid #615ced; box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08); } &.normal { border: 1px solid rgba(0, 0, 0, 0.04); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); } } .cardHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .mcpTitle { margin: 0; font-size: 17px; font-weight: 600; color: #1a1a1a; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .typeBadge { font-size: 10px; padding: 2px 6px; border-radius: 3px; font-weight: 500; &.local { background-color: #e6f7ff; color: #1890ff; border: 1px solid #91d5ff; } &.remote { background-color: #fff7e6; color: #fa8c16; border: 1px solid #ffd591; } } .fileIcon { font-size: 20px; display: flex; align-items: center; } .description { font-size: 14px; color: #666; line-height: 1.6; margin-bottom: 32px; min-height: 64px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .cardFooter { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding-top: 16px; margin-top: auto; border-top: 1px solid #f0f0f0; } .actionButton { padding: 0; font-size: 12px; } .deleteButton { padding: 4px; display: flex; align-items: center; justify-content: center; &:hover { background-color: #ff4d4f; color: #fff; border-color: #ff4d4f; } } .statusContainer { display: flex; align-items: center; gap: 6px; } .statusDot { width: 6px; height: 6px; border-radius: 50%; &.enabled { background-color: #52c41a; } &.disabled { background-color: #d9d9d9; } } .statusText { font-size: 12px; &.enabled { color: #52c41a; } &.disabled { color: #999; } } .editJsonTextArea { width: 100%; min-height: 500px; font-family: Monaco, Courier New, monospace; font-size: 13px; padding: 16px; border: 1px solid var(--ant-color-border); border-radius: 4px; resize: vertical; background-color: var(--ant-color-bg-container); color: var(--ant-color-text); } .preformattedText { background-color: var(--ant-color-bg-container-disabled, #f5f5f5); color: var(--ant-color-text); padding: 16px; border-radius: 8px; max-height: 500px; overflow: auto; } ================================================ FILE: console/src/pages/Agent/MCP/index.tsx ================================================ import { useState } from "react"; import { Button, Empty, Modal, Input } from "@agentscope-ai/design"; import type { MCPClientInfo } from "../../../api/types"; import { MCPClientCard } from "./components"; import { useMCP } from "./useMCP"; import { useTranslation } from "react-i18next"; type MCPTransport = "stdio" | "streamable_http" | "sse"; function normalizeTransport(raw?: unknown): MCPTransport | undefined { if (typeof raw !== "string") return undefined; const value = raw.trim().toLowerCase(); switch (value) { case "stdio": return "stdio"; case "sse": return "sse"; case "streamablehttp": case "streamable_http": case "streamable-http": case "http": return "streamable_http"; default: return undefined; } } function normalizeClientData(key: string, rawData: any) { const transport = normalizeTransport(rawData.transport ?? rawData.type) ?? (rawData.url || rawData.baseUrl || !rawData.command ? "streamable_http" : "stdio"); const command = transport === "stdio" ? (rawData.command ?? "").toString() : ""; return { name: rawData.name || key, description: rawData.description || "", enabled: rawData.enabled ?? rawData.isActive ?? true, transport, url: (rawData.url || rawData.baseUrl || "").toString(), headers: rawData.headers || {}, command, args: Array.isArray(rawData.args) ? rawData.args : [], env: rawData.env || {}, cwd: (rawData.cwd || "").toString(), }; } function MCPPage() { const { t } = useTranslation(); const { clients, loading, toggleEnabled, deleteClient, createClient, updateClient, } = useMCP(); const [hoverKey, setHoverKey] = useState(null); const [createModalOpen, setCreateModalOpen] = useState(false); const [newClientJson, setNewClientJson] = useState(`{ "mcpServers": { "example-client": { "command": "npx", "args": ["-y", "@example/mcp-server"], "env": { "API_KEY": "" } } } }`); const handleToggleEnabled = async ( client: MCPClientInfo, e?: React.MouseEvent, ) => { e?.stopPropagation(); await toggleEnabled(client); }; const handleDelete = async (client: MCPClientInfo, e?: React.MouseEvent) => { e?.stopPropagation(); await deleteClient(client); }; const handleCreateClient = async () => { try { const parsed = JSON.parse(newClientJson); // Support two formats: // Format 1: { "mcpServers": { "key": { "command": "...", ... } } } // Format 2: { "key": { "command": "...", ... } } // Format 3: { "key": "...", "name": "...", "command": "...", ... } (direct) const clientsToCreate: Array<{ key: string; data: any }> = []; if (parsed.mcpServers) { // Format 1: nested mcpServers Object.entries(parsed.mcpServers).forEach( ([key, data]: [string, any]) => { clientsToCreate.push({ key, data: normalizeClientData(key, data), }); }, ); } else if ( parsed.key && (parsed.command || parsed.url || parsed.baseUrl) ) { // Format 3: direct format with key field const { key, ...clientData } = parsed; clientsToCreate.push({ key, data: normalizeClientData(key, clientData), }); } else { // Format 2: direct client objects with keys Object.entries(parsed).forEach(([key, data]: [string, any]) => { if ( typeof data === "object" && (data.command || data.url || data.baseUrl) ) { clientsToCreate.push({ key, data: normalizeClientData(key, data), }); } }); } // Create all clients let allSuccess = true; for (const { key, data } of clientsToCreate) { const success = await createClient(key, data); if (!success) allSuccess = false; } if (allSuccess) { setCreateModalOpen(false); setNewClientJson(`{ "mcpServers": { "example-client": { "command": "npx", "args": ["-y", "@example/mcp-server"], "env": { "API_KEY": "" } } } }`); } } catch (error) { alert("Invalid JSON format"); } }; return (

{t("mcp.title")}

{t("mcp.description")}

{loading ? (

{t("common.loading")}

) : clients.length === 0 ? ( ) : (
{clients.map((client) => ( setHoverKey(client.key)} onMouseLeave={() => setHoverKey(null)} /> ))}
)} setCreateModalOpen(false)} footer={
} width={800} >

{t("mcp.formatSupport")}:

  • Standard format:{" "} {`{ "mcpServers": { "key": {...} } }`}
  • Direct format: {`{ "key": {...} }`}
  • Single format:{" "} {`{ "key": "...", "name": "...", "command": "..." }`}
setNewClientJson(e.target.value)} autoSize={{ minRows: 15, maxRows: 25 }} style={{ fontFamily: "Monaco, Courier New, monospace", fontSize: 13, }} />
); } export default MCPPage; ================================================ FILE: console/src/pages/Agent/MCP/useMCP.ts ================================================ import { useCallback, useEffect, useState } from "react"; import { message } from "@agentscope-ai/design"; import api from "../../../api"; import type { MCPClientInfo } from "../../../api/types"; import { useTranslation } from "react-i18next"; import { useAgentStore } from "../../../stores/agentStore"; export function useMCP() { const { t } = useTranslation(); const { selectedAgent } = useAgentStore(); const [clients, setClients] = useState([]); const [loading, setLoading] = useState(false); const loadClients = useCallback(async () => { setLoading(true); try { const data = await api.listMCPClients(); setClients(data); } catch (error) { console.error("Failed to load MCP clients:", error); message.error(t("mcp.loadError")); } finally { setLoading(false); } }, [t]); useEffect(() => { loadClients(); }, [loadClients, selectedAgent]); const createClient = useCallback( async ( key: string, clientData: { name: string; description?: string; command: string; enabled?: boolean; transport?: "stdio" | "streamable_http" | "sse"; url?: string; headers?: Record; args?: string[]; env?: Record; cwd?: string; }, ) => { try { await api.createMCPClient({ client_key: key, client: clientData, }); message.success(t("mcp.createSuccess")); await loadClients(); return true; } catch (error: any) { const errorMsg = error?.message || t("mcp.createError"); message.error(errorMsg); return false; } }, [t, loadClients], ); const updateClient = useCallback( async ( key: string, updates: { name?: string; description?: string; command?: string; enabled?: boolean; transport?: "stdio" | "streamable_http" | "sse"; url?: string; headers?: Record; args?: string[]; env?: Record; cwd?: string; }, ) => { try { await api.updateMCPClient(key, updates); message.success(t("mcp.updateSuccess")); await loadClients(); return true; } catch (error: any) { const errorMsg = error?.message || t("mcp.updateError"); message.error(errorMsg); return false; } }, [t, loadClients], ); const toggleEnabled = useCallback( async (client: MCPClientInfo) => { try { await api.toggleMCPClient(client.key); message.success( client.enabled ? t("mcp.disableSuccess") : t("mcp.enableSuccess"), ); await loadClients(); } catch (error) { message.error(t("mcp.toggleError")); } }, [t, loadClients], ); const deleteClient = useCallback( async (client: MCPClientInfo) => { try { await api.deleteMCPClient(client.key); message.success(t("mcp.deleteSuccess")); await loadClients(); } catch (error) { message.error(t("mcp.deleteError")); } }, [t, loadClients], ); return { clients, loading, createClient, updateClient, toggleEnabled, deleteClient, }; } ================================================ FILE: console/src/pages/Agent/Skills/components/SkillCard.tsx ================================================ import { Card, Button, Tooltip } from "@agentscope-ai/design"; import { DeleteOutlined, FileTextFilled, FileZipFilled, FilePdfFilled, FileWordFilled, FileExcelFilled, FilePptFilled, FileImageFilled, CodeFilled, } from "@ant-design/icons"; import type { SkillSpec } from "../../../../api/types"; import { useTranslation } from "react-i18next"; import styles from "../index.module.less"; interface SkillCardProps { skill: SkillSpec; isHover: boolean; onClick: () => void; onMouseEnter: () => void; onMouseLeave: () => void; onToggleEnabled: (e: React.MouseEvent) => void; onDelete?: (e?: React.MouseEvent) => void; } const getFileIcon = (filePath: string) => { const extension = filePath.split(".").pop()?.toLowerCase() || ""; switch (extension) { case "txt": case "md": case "markdown": return ; case "zip": case "rar": case "7z": case "tar": case "gz": return ; case "pdf": return ; case "doc": case "docx": return ; case "xls": case "xlsx": return ; case "ppt": case "pptx": return ; case "jpg": case "jpeg": case "png": case "gif": case "svg": case "webp": return ; case "py": case "js": case "ts": case "jsx": case "tsx": case "java": case "cpp": case "c": case "go": case "rs": case "rb": case "php": return ; default: return ; } }; export function SkillCard({ skill, isHover, onClick, onMouseEnter, onMouseLeave, onToggleEnabled, onDelete, }: SkillCardProps) { const { t } = useTranslation(); const isCustomized = skill.source === "customized"; const handleDeleteClick = (e: React.MouseEvent) => { e.stopPropagation(); if (!skill.enabled && onDelete) { onDelete(e); } }; return (
{getFileIcon(skill.name)}

{skill.name}

{skill.enabled ? t("common.enabled") : t("common.disabled")}
{t("skills.skillDescription")}
{skill.description || "-"}
{t("skills.source")}
{skill.source}
{t("skills.path")}
{skill.path}
{isCustomized && onDelete && (
); } ================================================ FILE: console/src/pages/Agent/Skills/components/SkillDrawer.tsx ================================================ import { useState, useEffect, useCallback, useRef } from "react"; import { Drawer, Form, Input, Button, message } from "@agentscope-ai/design"; import { useTranslation } from "react-i18next"; import { ThunderboltOutlined, StopOutlined } from "@ant-design/icons"; import type { FormInstance } from "antd"; import type { SkillSpec } from "../../../../api/types"; import { MarkdownCopy } from "../../../../components/MarkdownCopy/MarkdownCopy"; import { api } from "../../../../api"; /** * Parse frontmatter from content string. * Returns an object with parsed key-value pairs, or null if no valid frontmatter found. */ function parseFrontmatter(content: string): Record | null { const trimmed = content.trim(); if (!trimmed.startsWith("---")) return null; const endIndex = trimmed.indexOf("---", 3); if (endIndex === -1) return null; const frontmatterBlock = trimmed.slice(3, endIndex).trim(); if (!frontmatterBlock) return null; const result: Record = {}; for (const line of frontmatterBlock.split("\n")) { const colonIndex = line.indexOf(":"); if (colonIndex > 0) { const key = line.slice(0, colonIndex).trim(); const value = line.slice(colonIndex + 1).trim(); result[key] = value; } } return result; } interface SkillDrawerProps { open: boolean; editingSkill: SkillSpec | null; form: FormInstance; onClose: () => void; onSubmit: (values: SkillSpec) => void; onContentChange?: (content: string) => void; } export function SkillDrawer({ open, editingSkill, form, onClose, onSubmit, onContentChange, }: SkillDrawerProps) { const { t, i18n } = useTranslation(); const [showMarkdown, setShowMarkdown] = useState(true); const [contentValue, setContentValue] = useState(""); const [optimizing, setOptimizing] = useState(false); const abortControllerRef = useRef(null); const validateFrontmatter = useCallback( (_: unknown, value: string) => { const content = contentValue || value; if (!content || !content.trim()) { return Promise.reject(new Error(t("skills.pleaseInputContent"))); } const fm = parseFrontmatter(content); if (!fm) { return Promise.reject(new Error(t("skills.frontmatterRequired"))); } if (!fm.name) { return Promise.reject(new Error(t("skills.frontmatterNameRequired"))); } if (!fm.description) { return Promise.reject( new Error(t("skills.frontmatterDescriptionRequired")), ); } return Promise.resolve(); }, [contentValue, t], ); useEffect(() => { if (editingSkill) { setContentValue(editingSkill.content); form.setFieldsValue({ name: editingSkill.name, content: editingSkill.content, }); } else { setContentValue(""); form.resetFields(); } }, [editingSkill, form]); const handleSubmit = (values: { name: string; content: string }) => { if (editingSkill) { message.warning(t("skills.editNotSupported")); onClose(); } else { onSubmit({ ...values, content: contentValue || values.content, source: "", path: "", }); } }; const handleContentChange = (content: string) => { setContentValue(content); form.setFieldsValue({ content }); form.validateFields(["content"]).catch(() => {}); if (onContentChange) { onContentChange(content); } }; const handleOptimize = async () => { if (!contentValue.trim()) { message.warning(t("skills.noContentToOptimize")); return; } setOptimizing(true); abortControllerRef.current = new AbortController(); const originalContent = contentValue; setContentValue(""); // Clear content for streaming output try { await api.streamOptimizeSkill( originalContent, (textChunk) => { setContentValue((prev) => { const newContent = prev + textChunk; form.setFieldsValue({ content: newContent }); return newContent; }); }, abortControllerRef.current.signal, i18n.language, // Pass current language to API ); message.success(t("skills.optimizeSuccess")); } catch (error: any) { if (error.name !== "AbortError") { message.error(error.message || t("skills.optimizeFailed")); } } finally { setOptimizing(false); abortControllerRef.current = null; } }; const handleStopOptimize = () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); setOptimizing(false); abortControllerRef.current = null; } }; const drawerFooter = !editingSkill ? (
{!optimizing ? ( ) : ( )}
) : (
); return (
{!editingSkill && ( <> )} {editingSkill && ( <>

{t("skills.editNote")}

)}
); } ================================================ FILE: console/src/pages/Agent/Skills/components/index.ts ================================================ export { SkillCard } from "./SkillCard"; export { SkillDrawer } from "./SkillDrawer"; ================================================ FILE: console/src/pages/Agent/Skills/index.module.less ================================================ .skillsPage { padding: 24px; } .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } .headerInfo { display: flex; flex-direction: column; } .title { margin-bottom: 4px; font-size: 24px; font-weight: 600; } .description { margin: 0; color: #999; font-size: 14px; } .loading { text-align: center; padding: 60px; } .loadingText { color: #999; } .importHintBlock { margin-bottom: 12px; } .importHintTitle { margin: 0; font-size: 13px; color: #666; } .importHintList { margin: 8px 0; padding: 0 0 0 20px; font-size: 12px; color: #999; } .importUrlInput { width: 100%; height: 40px; padding: 0 12px; border: 1px solid #d9d9d9; border-radius: 8px; outline: none; font-size: 14px; &:focus { border-color: #615ced; } } .importUrlError { margin-top: 8px; color: #ff4d4f; font-size: 13px; } .importLoadingText { margin-top: 8px; font-size: 13px; color: #666; } .skillsGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 24px; } .skillCard { border-radius: 16px; transition: all 0.2s ease-in-out; cursor: pointer; overflow: hidden; :global(.ant-card-body) { padding: 16px 16px 12px; } &.enabledCard { border: 2px solid #615ced !important; box-shadow: rgba(97, 92, 237, 0.2) 0px 8px 24px !important; .statusDot.enabled { background-color: #52c41a; } .statusText.enabled { color: #52c41a; } } &.hover { border: 1px solid #615ced; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.08); } &.normal { border: 1px solid rgba(0, 0, 0, 0.04); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04); } } .cardBody { display: flex; flex-direction: column; gap: 12px; } .cardHeader { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; } .skillTitle { margin: 0; font-size: 17px; font-weight: 600; color: #1a1a1a; line-height: 1.3; } .fileIcon { font-size: 22px; display: flex; align-items: center; } .statusContainer { display: flex; align-items: center; gap: 6px; } .statusDot { width: 6px; height: 6px; border-radius: 50%; &.enabled { background-color: #52c41a; } &.disabled { background-color: #d9d9d9; } } .statusText { font-size: 12px; &.enabled { color: #52c41a; } &.disabled { color: #999; } } .infoSection { display: flex; flex-direction: column; gap: 6px; min-width: 0; } .descriptionSection { display: flex; flex-direction: column; gap: 6px; } .infoBlock { font-size: 12px; color: #525866; background-color: #f5f6fa; border: 1px solid #eceff6; border-radius: 8px; padding: 8px 10px; } .descriptionContent { line-height: 1.45; word-break: break-word; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; min-height: 64px; max-height: 64px; } .metaStack { display: flex; flex-direction: column; gap: 10px; } .infoLabel { font-size: 12px; color: #999; margin-bottom: 4px; } .singleLineValue { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .builtinTag { font-size: 12px; color: #615ced; background: rgba(97, 92, 237, 0.1); border-radius: 4px; padding: 1px 6px; white-space: nowrap; } .customizedTag { font-size: 12px; color: #fa8c16; background: rgba(250, 140, 22, 0.1); border-radius: 4px; padding: 1px 6px; white-space: nowrap; } .pathValue { max-width: 100%; } .cardFooter { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding-top: 10px; margin-top: 2px; border-top: 1px solid #f1f2f6; } .actionButton { padding: 0; } .deleteButton { padding: 4px; display: flex; align-items: center; justify-content: center; &:hover { background-color: #ff4d4f; color: #fff; border-color: #ff4d4f; } } .contentLabel { font-size: 12px; font-weight: 600; display: flex; justify-content: space-between; align-items: center; height: 20px; width: 100%; label { width: 100%; } } .buttonGroup { display: flex; align-items: center; } .markdownToggle { display: flex; align-items: center; gap: 8px; } .toggleLabel { font-size: 12px; color: #666; white-space: nowrap; } .markdownViewer { min-height: 200px; max-height: 300px; overflow: auto; border: 1px solid #d9d9d9; border-radius: 6px; background-color: #fff; } .copyButton { border-radius: 4px; } @media (max-width: 768px) { .descriptionContent { min-height: 50px; } } /* ─── Dark mode ─────────────────────────────────────────────────────────────── */ :global(.dark-mode) { .description { color: rgba(255, 255, 255, 0.35); } .loadingText { color: rgba(255, 255, 255, 0.35); } /* Import modal hints */ .importHintTitle { color: rgba(255, 255, 255, 0.55); } .importHintList { color: rgba(255, 255, 255, 0.35); } .importUrlInput { background: #2a2a2a; border-color: rgba(255, 255, 255, 0.15); color: rgba(255, 255, 255, 0.85); &::placeholder { color: rgba(255, 255, 255, 0.25); } &:focus { border-color: #615ced; } } .importLoadingText { color: rgba(255, 255, 255, 0.45); } /* Skill card */ .skillCard { &.normal { border-color: rgba(255, 255, 255, 0.08) !important; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important; } &.hover { border-color: #615ced !important; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4) !important; } } .skillTitle { color: rgba(255, 255, 255, 0.85); } .statusDot.disabled { background-color: rgba(255, 255, 255, 0.2); } .statusText.disabled { color: rgba(255, 255, 255, 0.3); } /* Description info block */ .infoBlock { color: rgba(255, 255, 255, 0.65); background-color: rgba(255, 255, 255, 0.05); border-color: rgba(255, 255, 255, 0.08); } /* Source / Path labels */ .infoLabel { color: rgba(255, 255, 255, 0.3); } /* Card footer divider */ .cardFooter { border-top-color: rgba(255, 255, 255, 0.08); } /* Toggle label */ .toggleLabel { color: rgba(255, 255, 255, 0.45); } /* Markdown viewer */ .markdownViewer { border-color: rgba(255, 255, 255, 0.1); background-color: #1f1f1f; color: rgba(255, 255, 255, 0.85); } } ================================================ FILE: console/src/pages/Agent/Skills/index.tsx ================================================ import { useState, useRef } from "react"; import { Button, Form, Modal, message } from "@agentscope-ai/design"; import { DownloadOutlined, PlusOutlined, UploadOutlined, } from "@ant-design/icons"; import type { SkillSpec } from "../../../api/types"; import { SkillCard, SkillDrawer } from "./components"; import { useSkills } from "./useSkills"; import { useTranslation } from "react-i18next"; import styles from "./index.module.less"; function SkillsPage() { const { t } = useTranslation(); const { skills, loading, uploading, importing, cancelImport, createSkill, uploadSkill, importFromHub, toggleEnabled, deleteSkill, } = useSkills(); const [drawerOpen, setDrawerOpen] = useState(false); const [importModalOpen, setImportModalOpen] = useState(false); const [importUrl, setImportUrl] = useState(""); const [importUrlError, setImportUrlError] = useState(""); const [editingSkill, setEditingSkill] = useState(null); const [hoverKey, setHoverKey] = useState(null); const [form] = Form.useForm(); const fileInputRef = useRef(null); const MAX_UPLOAD_SIZE_MB = 100; const handleUploadClick = () => { fileInputRef.current?.click(); }; const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; // Reset input so the same file can be re-selected e.target.value = ""; if (!file.name.toLowerCase().endsWith(".zip")) { message.warning(t("skills.zipOnly")); return; } const sizeMB = file.size / (1024 * 1024); if (sizeMB > MAX_UPLOAD_SIZE_MB) { message.warning( t("skills.fileSizeExceeded", { size: sizeMB.toFixed(1) }), ); return; } await uploadSkill(file); }; const supportedSkillUrlPrefixes = [ "https://skills.sh/", "https://clawhub.ai/", "https://skillsmp.com/", "https://lobehub.com/", "https://market.lobehub.com/", "https://github.com/", "https://modelscope.cn/skills/", ]; const isSupportedSkillUrl = (url: string) => { return supportedSkillUrlPrefixes.some((prefix) => url.startsWith(prefix)); }; const handleCreate = () => { setEditingSkill(null); form.resetFields(); form.setFieldsValue({ enabled: false, }); setDrawerOpen(true); }; const closeImportModal = () => { if (importing) { return; } setImportModalOpen(false); setImportUrl(""); setImportUrlError(""); }; const handleImportFromHub = () => { setImportModalOpen(true); }; const handleImportUrlChange = (value: string) => { setImportUrl(value); const trimmed = value.trim(); if (trimmed && !isSupportedSkillUrl(trimmed)) { setImportUrlError(t("skills.invalidSkillUrlSource")); return; } setImportUrlError(""); }; const handleConfirmImport = async () => { if (importing) return; const trimmed = importUrl.trim(); if (!trimmed) return; if (!isSupportedSkillUrl(trimmed)) { setImportUrlError(t("skills.invalidSkillUrlSource")); return; } const success = await importFromHub(trimmed); if (success) { closeImportModal(); } }; const handleEdit = (skill: SkillSpec) => { setEditingSkill(skill); form.setFieldsValue(skill); setDrawerOpen(true); }; const handleToggleEnabled = async (skill: SkillSpec, e: React.MouseEvent) => { e.stopPropagation(); await toggleEnabled(skill); }; const handleDelete = async (skill: SkillSpec, e?: React.MouseEvent) => { e?.stopPropagation(); await deleteSkill(skill); }; const handleDrawerClose = () => { setDrawerOpen(false); setEditingSkill(null); }; const handleSubmit = async (values: { name: string; content: string }) => { try { const success = await createSkill(values.name, values.content); if (success) { setDrawerOpen(false); } } catch (error) { console.error("Submit failed", error); } }; return (

{t("skills.title")}

{t("skills.description")}

} width={760} >

{t("skills.supportedSkillUrlSources")}

  • https://skills.sh/
  • https://clawhub.ai/
  • https://skillsmp.com/
  • https://lobehub.com/
  • https://market.lobehub.com/
  • https://github.com/
  • https://modelscope.cn/skills/

{t("skills.urlExamples")}

  • https://skills.sh/vercel-labs/skills/find-skills
  • https://lobehub.com/zh/skills/openclaw-skills-cli-developer
  • https://market.lobehub.com/api/v1/skills/openclaw-skills-cli-developer/download
  • https://github.com/anthropics/skills/tree/main/skills/skill-creator
  • https://modelscope.cn/skills/@anthropics/skill-creator
handleImportUrlChange(e.target.value)} placeholder={t("skills.enterSkillUrl")} disabled={importing} /> {importUrlError ? (
{importUrlError}
) : null} {importing ? (
{t("common.loading")}
) : null} {loading ? (
{t("common.loading")}
) : (
{skills .slice() .sort((a, b) => { if (a.enabled && !b.enabled) return -1; if (!a.enabled && b.enabled) return 1; return a.name.localeCompare(b.name); }) .map((skill) => ( handleEdit(skill)} onMouseEnter={() => setHoverKey(skill.name)} onMouseLeave={() => setHoverKey(null)} onToggleEnabled={(e) => handleToggleEnabled(skill, e)} onDelete={(e) => handleDelete(skill, e)} /> ))}
)} ); } export default SkillsPage; ================================================ FILE: console/src/pages/Agent/Skills/useSkills.ts ================================================ import { useState, useEffect, useCallback, useRef } from "react"; import { message, Modal } from "@agentscope-ai/design"; import React from "react"; import api from "../../../api"; import type { SkillSpec } from "../../../api/types"; import type { SecurityScanErrorResponse } from "../../../api/modules/security"; import { useTranslation } from "react-i18next"; import { useAgentStore } from "../../../stores/agentStore"; function tryParseScanError(error: unknown): SecurityScanErrorResponse | null { if (!(error instanceof Error)) return null; const msg = error.message || ""; const jsonStart = msg.indexOf("{"); if (jsonStart === -1) return null; try { const parsed = JSON.parse(msg.substring(jsonStart)); if (parsed?.type === "security_scan_failed") { return parsed as SecurityScanErrorResponse; } } catch { // not JSON } return null; } export function useSkills() { const { t } = useTranslation(); const { selectedAgent } = useAgentStore(); const [skills, setSkills] = useState([]); const [loading, setLoading] = useState(false); const [uploading, setUploading] = useState(false); const [importing, setImporting] = useState(false); const importTaskIdRef = useRef(null); const importCancelReasonRef = useRef<"manual" | "timeout" | null>(null); const showScanErrorModal = useCallback( (scanError: SecurityScanErrorResponse) => { const findings = scanError.findings || []; Modal.error({ title: t("security.skillScanner.scanError.title"), width: 640, content: React.createElement( "div", null, React.createElement( "p", null, t("security.skillScanner.scanError.description"), ), React.createElement( "div", { style: { maxHeight: 300, overflow: "auto", marginTop: 8, }, }, findings.map((f, i) => React.createElement( "div", { key: i, style: { padding: "8px 12px", marginBottom: 4, background: "#fafafa", borderRadius: 6, border: "1px solid #f0f0f0", }, }, React.createElement( "strong", { style: { marginBottom: 4, display: "block" } }, f.title, ), React.createElement( "div", { style: { fontSize: 12, color: "#666" } }, f.file_path + (f.line_number ? `:${f.line_number}` : ""), ), f.description && React.createElement( "div", { style: { fontSize: 12, color: "#999", marginTop: 2, }, }, f.description, ), ), ), ), ), }); }, [t], ); const handleError = useCallback( (error: unknown, defaultMsg: string): boolean => { const scanError = tryParseScanError(error); if (scanError) { showScanErrorModal(scanError); return true; } const msg = error instanceof Error && error.message ? error.message : defaultMsg; console.error(defaultMsg, error); message.error(msg); return false; }, [showScanErrorModal], ); const checkScanWarnings = useCallback( async (skillName: string) => { try { const [alerts, scannerCfg] = await Promise.all([ api.getBlockedHistory(), api.getSkillScanner(), ]); if (!alerts.length) return; if ( scannerCfg?.whitelist?.some( (w: { skill_name: string }) => w.skill_name === skillName, ) ) return; const latestForSkill = alerts .filter((a) => a.skill_name === skillName && a.action === "warned") .pop(); if (!latestForSkill) return; const findings = latestForSkill.findings || []; Modal.warning({ title: t("security.skillScanner.scanError.title"), width: 640, content: React.createElement( "div", null, React.createElement( "p", null, t("security.skillScanner.scanError.warnDescription"), ), React.createElement( "div", { style: { maxHeight: 300, overflow: "auto", marginTop: 8 } }, findings.map((f, i) => React.createElement( "div", { key: i, style: { padding: "8px 12px", marginBottom: 4, background: "#fafafa", borderRadius: 6, border: "1px solid #f0f0f0", }, }, React.createElement( "strong", { style: { marginBottom: 4, display: "block" } }, f.title, ), React.createElement( "div", { style: { fontSize: 12, color: "#666" } }, f.file_path + (f.line_number ? `:${f.line_number}` : ""), ), f.description && React.createElement( "div", { style: { fontSize: 12, color: "#999", marginTop: 2 } }, f.description, ), ), ), ), ), }); } catch { // non-critical } }, [t], ); const fetchSkills = async () => { setLoading(true); try { const data = await api.listSkills(); if (data) { setSkills(data); } } catch (error) { console.error("Failed to load skills", error); message.error("Failed to load skills"); } finally { setLoading(false); } }; useEffect(() => { let mounted = true; const loadSkills = async () => { await fetchSkills(); }; if (mounted) { loadSkills(); } return () => { mounted = false; }; }, [selectedAgent]); const createSkill = async (name: string, content: string) => { try { await api.createSkill(name, content); message.success("Created successfully"); await fetchSkills(); await checkScanWarnings(name); return true; } catch (error) { handleError(error, "Failed to save"); return false; } }; const uploadSkill = async (file: File) => { try { setUploading(true); const result = await api.uploadSkill(file, { enable: false, overwrite: false, }); if (result?.count > 0) { message.success( t("skills.uploadSuccess") + `: ${result.imported.join(", ")}`, ); await fetchSkills(); for (const name of result.imported) { await checkScanWarnings(name); } return true; } message.warning(t("skills.uploadNoChange")); await fetchSkills(); return true; } catch (error) { handleError(error, t("skills.uploadFailed")); return false; } finally { setUploading(false); } }; const importFromHub = async (input: string) => { const text = (input || "").trim(); if (!text) { message.warning("Please provide a hub skill URL"); return false; } if (!text.startsWith("http://") && !text.startsWith("https://")) { message.warning( "Please enter a valid URL starting with http:// or https://", ); return false; } const timeoutMs = 90_000; const pollMs = 1_000; const startedAt = Date.now(); try { setImporting(true); importCancelReasonRef.current = null; const payload = { bundle_url: text, enable: true, overwrite: false }; const task = await api.startHubSkillInstall(payload); importTaskIdRef.current = task.task_id; while (importTaskIdRef.current) { const status = await api.getHubSkillInstallStatus(task.task_id); if (status.status === "completed" && status.result?.installed) { message.success(`Imported skill: ${status.result.name}`); await fetchSkills(); if (status.result.name) await checkScanWarnings(status.result.name); return true; } if (status.status === "failed") { throw new Error(status.error || "Import failed"); } if (status.status === "cancelled") { message.warning( t( importCancelReasonRef.current === "timeout" ? "skills.importTimeout" : "skills.importCancelled", ), ); return false; } if (Date.now() - startedAt >= timeoutMs) { importCancelReasonRef.current = "timeout"; await api.cancelHubSkillInstall(task.task_id); } await new Promise((resolve) => window.setTimeout(resolve, pollMs)); } return false; } catch (error) { handleError(error, "Import failed"); return false; } finally { importTaskIdRef.current = null; importCancelReasonRef.current = null; setImporting(false); } }; const cancelImport = useCallback(() => { if (!importing) return; importCancelReasonRef.current = "manual"; const taskId = importTaskIdRef.current; if (!taskId) return; void api.cancelHubSkillInstall(taskId); }, [importing]); const toggleEnabled = async (skill: SkillSpec) => { try { if (skill.enabled) { await api.disableSkill(skill.name); setSkills((prev) => prev.map((s) => s.name === skill.name ? { ...s, enabled: false } : s, ), ); message.success("Disabled successfully"); } else { await api.enableSkill(skill.name); setSkills((prev) => prev.map((s) => s.name === skill.name ? { ...s, enabled: true } : s, ), ); message.success("Enabled successfully"); await checkScanWarnings(skill.name); } return true; } catch (error) { handleError(error, "Operation failed"); return false; } }; const deleteSkill = async (skill: SkillSpec) => { const confirmed = await new Promise((resolve) => { Modal.confirm({ title: "Confirm Delete", content: `Are you sure you want to delete skill "${skill.name}"? This action cannot be undone.`, okText: "Delete", okType: "danger", cancelText: "Cancel", onOk: () => resolve(true), onCancel: () => resolve(false), }); }); if (!confirmed) return false; try { const result = await api.deleteSkill(skill.name); if (result.deleted) { message.success("Deleted successfully"); await fetchSkills(); return true; } else { message.error("Failed to delete skill"); return false; } } catch (error) { console.error("Failed to delete skill", error); message.error("Failed to delete skill"); return false; } }; return { skills, loading, uploading, importing, cancelImport, createSkill, uploadSkill, importFromHub, toggleEnabled, deleteSkill, }; } ================================================ FILE: console/src/pages/Agent/Tools/index.module.less ================================================ .toolsPage { padding: 24px; } .header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; } .headerInfo { display: flex; flex-direction: column; } .actionTabs { display: flex; gap: 4px; padding: 4px; background: rgba(0, 0, 0, 0.04); border-radius: 10px; align-self: center; } .actionTab { padding: 5px 14px; border-radius: 7px; border: none; font-size: 13px; color: #999; background: transparent; cursor: pointer; transition: all 0.18s ease; &:hover { color: #666; background: rgba(0, 0, 0, 0.04); } } .disabledTab { color: #615ced !important; background: #fff !important; cursor: not-allowed; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); &:hover { color: #615ced !important; background: #fff !important; } } .title { margin-bottom: 4px; font-size: 24px; font-weight: 600; } .description { margin: 0; color: #999; font-size: 14px; } .loading { text-align: center; padding: 60px; color: #999; } .toolsGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 24px; } .toolCard { border-radius: 16px; transition: all 0.2s ease-in-out; cursor: default; &.enabledCard { border: 2px solid #615ced !important; box-shadow: rgba(97, 92, 237, 0.2) 0px 8px 24px !important; .statusDot { background-color: #52c41a; } .statusText { color: #52c41a; } } &.hoverCard { border: 1px solid #615ced; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.08); } &.normalCard { border: 1px solid rgba(0, 0, 0, 0.04); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04); } } .cardHeader { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; } .toolName { margin: 0; font-size: 16px; font-weight: 600; color: #1a1a1a; font-family: Monaco, "Courier New", monospace; } .statusContainer { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } .statusDot { width: 6px; height: 6px; border-radius: 50%; background-color: #d9d9d9; } .statusText { font-size: 12px; color: #999; } .toolDescription { margin: 0 0 16px 0; color: #666; font-size: 13px; line-height: 1.6; min-height: 40px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .cardFooter { display: flex; justify-content: flex-end; align-items: center; padding-top: 12px; border-top: 1px solid #f0f0f0; } ================================================ FILE: console/src/pages/Agent/Tools/index.tsx ================================================ import { useState, useMemo } from "react"; import { Card, Switch, Empty, Button } from "@agentscope-ai/design"; import { useTools } from "./useTools"; import { useTranslation } from "react-i18next"; import type { ToolInfo } from "../../../api/modules/tools"; import styles from "./index.module.less"; export default function ToolsPage() { const { t } = useTranslation(); const { tools, loading, batchLoading, toggleEnabled, enableAll, disableAll } = useTools(); const [hoverKey, setHoverKey] = useState(null); const handleToggle = (tool: ToolInfo) => { toggleEnabled(tool); }; const hasDisabledTools = useMemo( () => tools.some((tool) => !tool.enabled), [tools], ); const hasEnabledTools = useMemo( () => tools.some((tool) => tool.enabled), [tools], ); return (

{t("tools.title")}

{t("tools.description")}

{loading ? (

{t("common.loading")}

) : tools.length === 0 ? ( ) : (
{tools.map((tool) => ( setHoverKey(tool.name)} onMouseLeave={() => setHoverKey(null)} >

{tool.name}

{tool.enabled ? t("common.enabled") : t("common.disabled")}

{tool.description}

handleToggle(tool)} />
))}
)}
); } ================================================ FILE: console/src/pages/Agent/Tools/useTools.ts ================================================ import { useCallback, useEffect, useState } from "react"; import { message } from "@agentscope-ai/design"; import api from "../../../api"; import type { ToolInfo } from "../../../api/modules/tools"; import { useTranslation } from "react-i18next"; import { useAgentStore } from "../../../stores/agentStore"; export function useTools() { const { t } = useTranslation(); const { selectedAgent } = useAgentStore(); const [tools, setTools] = useState([]); const [loading, setLoading] = useState(false); const [batchLoading, setBatchLoading] = useState(false); const loadTools = useCallback(async () => { setLoading(true); try { const data = await api.listTools(); setTools(data); } catch (error) { console.error("Failed to load tools:", error); message.error(t("tools.loadError")); } finally { setLoading(false); } }, [t]); useEffect(() => { loadTools(); }, [loadTools, selectedAgent]); const toggleEnabled = useCallback( async (tool: ToolInfo) => { // Optimistic update setTools((prev) => prev.map((t) => t.name === tool.name ? { ...t, enabled: !t.enabled } : t, ), ); try { const result = await api.toggleTool(tool.name); message.success( tool.enabled ? t("tools.disableSuccess") : t("tools.enableSuccess"), ); // Update with server response (no full reload) setTools((prev) => prev.map((t) => (t.name === result.name ? result : t)), ); } catch (error) { // Revert optimistic update on error setTools((prev) => prev.map((t) => t.name === tool.name ? { ...t, enabled: tool.enabled } : t, ), ); message.error(t("tools.toggleError")); } }, [t], ); const enableAll = useCallback(async () => { const disabledTools = tools.filter((tool) => !tool.enabled); if (disabledTools.length === 0) { message.info(t("tools.allEnabled")); return; } // Optimistic update setTools((prev) => prev.map((t) => ({ ...t, enabled: true }))); setBatchLoading(true); try { const results = await Promise.all( disabledTools.map((tool) => api.toggleTool(tool.name)), ); message.success(t("tools.enableAllSuccess")); // Update with server responses setTools((prev) => prev.map((t) => { const result = results.find((r) => r.name === t.name); return result || t; }), ); } catch (error) { message.error(t("tools.toggleError")); // Reload on error to sync with server await loadTools(); } finally { setBatchLoading(false); } }, [tools, t, loadTools]); const disableAll = useCallback(async () => { const enabledTools = tools.filter((tool) => tool.enabled); if (enabledTools.length === 0) { message.info(t("tools.allDisabled")); return; } // Optimistic update setTools((prev) => prev.map((t) => ({ ...t, enabled: false }))); setBatchLoading(true); try { const results = await Promise.all( enabledTools.map((tool) => api.toggleTool(tool.name)), ); message.success(t("tools.disableAllSuccess")); // Update with server responses setTools((prev) => prev.map((t) => { const result = results.find((r) => r.name === t.name); return result || t; }), ); } catch (error) { message.error(t("tools.toggleError")); // Reload on error to sync with server await loadTools(); } finally { setBatchLoading(false); } }, [tools, t, loadTools]); return { tools, loading, batchLoading, toggleEnabled, enableAll, disableAll, }; } ================================================ FILE: console/src/pages/Agent/Workspace/components/FileEditor.tsx ================================================ import React, { useState, useMemo } from "react"; import { Button, Card, Input, Switch, message } from "@agentscope-ai/design"; import { CopyOutlined, UndoOutlined, SaveOutlined } from "@ant-design/icons"; import type { MarkdownFile } from "../../../../api/types"; import { XMarkdown } from "@ant-design/x-markdown"; import { useTranslation } from "react-i18next"; import { stripFrontmatter } from "../../../../utils/markdown"; import styles from "../index.module.less"; interface FileEditorProps { selectedFile: MarkdownFile | null; fileContent: string; loading: boolean; hasChanges: boolean; onContentChange: (content: string) => void; onSave: () => void; onReset: () => void; } export const FileEditor: React.FC = ({ selectedFile, fileContent, loading, hasChanges, onContentChange, onSave, onReset, }) => { const { t } = useTranslation(); const [showMarkdown, setShowMarkdown] = useState(true); const isMarkdownFile = selectedFile?.filename.endsWith(".md") || false; const markdownContent = useMemo( () => stripFrontmatter(fileContent || ""), [fileContent], ); const copyToClipboard = async () => { try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(fileContent); message.success(t("common.copied")); } else { const textArea = document.createElement("textarea"); textArea.value = fileContent; textArea.style.position = "fixed"; textArea.style.left = "-999999px"; textArea.style.top = "-999999px"; document.body.appendChild(textArea); textArea.focus(); textArea.select(); document.execCommand("copy"); textArea.remove(); message.success(t("common.copied")); } } catch (err) { console.error("Failed to copy text: ", err); message.error(t("common.copyFailed")); } }; return (
{selectedFile ? ( <>
{selectedFile.filename}
{selectedFile.path}
{t("common.content")}
{isMarkdownFile && (
{t("common.preview")}
)}
{showMarkdown && isMarkdownFile ? ( ) : ( onContentChange(e.target.value)} className={styles.textarea} placeholder={t("workspace.fileContent")} /> )}
) : (
{t("workspace.selectFile")}
)}
); }; ================================================ FILE: console/src/pages/Agent/Workspace/components/FileItem.tsx ================================================ import React from "react"; import { Switch, Tooltip } from "@agentscope-ai/design"; import { CaretDownOutlined, CaretRightOutlined, HolderOutlined, } from "@ant-design/icons"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import type { MarkdownFile, DailyMemoryFile } from "../../../../api/types"; import { formatFileSize, formatTimeAgo } from "./utils"; import { useTranslation } from "react-i18next"; import styles from "../index.module.less"; interface FileItemProps { file: MarkdownFile; selectedFile: MarkdownFile | null; expandedMemory: boolean; dailyMemories: DailyMemoryFile[]; enabled?: boolean; onFileClick: (file: MarkdownFile) => void; onDailyMemoryClick: (daily: DailyMemoryFile) => void; onToggleEnabled: (filename: string) => void; } export const FileItem: React.FC = ({ file, selectedFile, expandedMemory, dailyMemories, enabled = false, onFileClick, onDailyMemoryClick, onToggleEnabled, }) => { const { t } = useTranslation(); const isSelected = selectedFile?.filename === file.filename; const isMemoryFile = file.filename === "MEMORY.md"; const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: file.filename, disabled: !enabled, }); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, position: "relative", zIndex: isDragging ? 1 : undefined, }; const handleToggleClick = ( _checked: boolean, event: | React.MouseEvent | React.KeyboardEvent, ) => { event.stopPropagation(); onToggleEnabled(file.filename); }; return (
onFileClick(file)} className={`${styles.fileItem} ${isSelected ? styles.selected : ""} ${ isDragging ? styles.dragging : "" }`} >
{enabled && (
e.stopPropagation()} >
)}
{enabled && } {file.filename}
{formatFileSize(file.size)} · {formatTimeAgo(file.updated_at)}
{isMemoryFile && ( {expandedMemory ? ( ) : ( )} )}
{isMemoryFile && expandedMemory && (
{dailyMemories.map((daily) => { const isDailySelected = selectedFile?.filename === `${daily.date}.md`; return (
onDailyMemoryClick(daily)} className={`${styles.dailyMemoryItem} ${ isDailySelected ? styles.selected : "" }`} >
{daily.date}.md
{formatFileSize(daily.size)} ·{" "} {formatTimeAgo(daily.updated_at)}
); })}
)}
); }; ================================================ FILE: console/src/pages/Agent/Workspace/components/FileListPanel.tsx ================================================ import React from "react"; import { Button, Card } from "@agentscope-ai/design"; import { ReloadOutlined } from "@ant-design/icons"; import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy, arrayMove, } from "@dnd-kit/sortable"; import type { MarkdownFile, DailyMemoryFile } from "../../../../api/types"; import { FileItem } from "./FileItem"; import { useTranslation } from "react-i18next"; import styles from "../index.module.less"; interface FileListPanelProps { files: MarkdownFile[]; selectedFile: MarkdownFile | null; dailyMemories: DailyMemoryFile[]; expandedMemory: boolean; workspacePath: string | null; enabledFiles: string[]; onRefresh: () => void; onFileClick: (file: MarkdownFile) => void; onDailyMemoryClick: (daily: DailyMemoryFile) => void; onToggleEnabled: (filename: string) => void; onReorder: (newOrder: string[]) => void; } export const FileListPanel: React.FC = ({ files, selectedFile, dailyMemories, expandedMemory, enabledFiles, onRefresh, onFileClick, onDailyMemoryClick, onToggleEnabled, onReorder, }) => { const { t } = useTranslation(); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5, }, }), ); const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = enabledFiles.indexOf(active.id as string); const newIndex = enabledFiles.indexOf(over.id as string); if (oldIndex === -1 || newIndex === -1) return; const newOrder = arrayMove(enabledFiles, oldIndex, newIndex); onReorder(newOrder); }; return (

{t("workspace.coreFiles")}

{t("workspace.coreFilesDesc")}

{files.length > 0 ? ( {files.map((file) => { const isEnabled = enabledFiles.includes(file.filename); return ( ); })} ) : (
{t("workspace.noFiles")}
)}
); }; ================================================ FILE: console/src/pages/Agent/Workspace/components/index.ts ================================================ export { FileListPanel } from "./FileListPanel"; export { FileEditor } from "./FileEditor"; export { FileItem } from "./FileItem"; export { useAgentsData } from "./useAgentsData"; export * from "./utils"; ================================================ FILE: console/src/pages/Agent/Workspace/components/useAgentsData.ts ================================================ import { useState, useEffect } from "react"; import { message } from "@agentscope-ai/design"; import { useTranslation } from "react-i18next"; import api from "../../../../api"; import type { MarkdownFile, DailyMemoryFile } from "../../../../api/types"; import { workspaceApi } from "../../../../api/modules/workspace"; import { agentsApi } from "../../../../api/modules/agents"; import { useAgentStore } from "../../../../stores/agentStore"; // Returns the parent directory of a file path, supporting both '/' and '\' separators. const getParentDir = (filePath: string): string => { const match = filePath.match(/^(.*)[/\\]/); return match ? match[1] : filePath; }; export const useAgentsData = () => { const { t } = useTranslation(); const { selectedAgent } = useAgentStore(); const [files, setFiles] = useState([]); const [selectedFile, setSelectedFile] = useState(null); const [dailyMemories, setDailyMemories] = useState([]); const [expandedMemory, setExpandedMemory] = useState(false); const [fileContent, setFileContent] = useState(""); const [originalContent, setOriginalContent] = useState(""); const [loading, setLoading] = useState(false); const [workspacePath, setWorkspacePath] = useState(null); const [enabledFiles, setEnabledFiles] = useState([]); useEffect(() => { const initializeData = async () => { // Remember currently selected file name const previouslySelectedFilename = selectedFile?.filename; // Clear content first setFileContent(""); setOriginalContent(""); setExpandedMemory(false); const enabled = await fetchEnabledFiles(); const fileList = await agentsApi.listAgentFiles(selectedAgent); const sortedFiles = sortFilesByEnabled( fileList as unknown as MarkdownFile[], enabled, ); setFiles(sortedFiles); // Set workspace path (handle both Unix '/' and Windows '\' separators) if (fileList.length > 0) { setWorkspacePath(getParentDir(fileList[0].path)); } else { setWorkspacePath(""); } // Try to re-select the same file in new workspace if (previouslySelectedFilename) { const sameFile = sortedFiles.find( (f) => f.filename === previouslySelectedFilename, ); if (sameFile) { // Auto-load the same file from new workspace await handleFileClick(sameFile); } else { // File doesn't exist in new workspace, clear selection setSelectedFile(null); } } else { setSelectedFile(null); } }; initializeData(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedAgent]); // Re-sort when enabledFiles changes (for toggle/reorder operations) useEffect(() => { if (files.length > 0 && enabledFiles.length >= 0) { const sortedFiles = sortFilesByEnabled(files, enabledFiles); // Only update if order actually changed to avoid infinite loop const orderChanged = sortedFiles.some( (file, index) => file.filename !== files[index]?.filename, ); if (orderChanged) { setFiles(sortedFiles); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabledFiles]); const fetchEnabledFiles = async () => { try { const result = await workspaceApi.getSystemPromptFiles(); const enabled = Array.isArray(result) ? result : []; setEnabledFiles(enabled); return enabled; } catch (error) { console.error("Failed to fetch enabled files", error); return []; } }; const sortFilesByEnabled = ( fileList: MarkdownFile[], currentEnabledFiles: string[], ) => { const safeEnabled = Array.isArray(currentEnabledFiles) ? currentEnabledFiles : []; return [...fileList].sort((a, b) => { const aIndex = safeEnabled.indexOf(a.filename); const bIndex = safeEnabled.indexOf(b.filename); const aEnabled = aIndex !== -1; const bEnabled = bIndex !== -1; if (aEnabled && bEnabled) { return aIndex - bIndex; } if (aEnabled) return -1; if (bEnabled) return 1; return a.filename.localeCompare(b.filename); }); }; const fetchFiles = async (latestEnabledFiles?: string[]) => { try { // Validate with Array.isArray: onClick handlers may pass a MouseEvent as the first argument const enabled = Array.isArray(latestEnabledFiles) ? latestEnabledFiles : await fetchEnabledFiles(); // Use agent-specific API const fileList = await agentsApi.listAgentFiles(selectedAgent); const sortedFiles = sortFilesByEnabled( fileList as unknown as MarkdownFile[], enabled, ); setFiles(sortedFiles); // Set workspace path (handle both Unix '/' and Windows '\' separators) if (fileList.length > 0) { setWorkspacePath(getParentDir(fileList[0].path)); } else { setWorkspacePath(""); } } catch (error) { console.error("Failed to fetch files", error); message.error("Failed to load file list"); } }; const fetchDailyMemories = async () => { try { const memoryList = await api.listDailyMemory(); setDailyMemories(memoryList); } catch (error) { console.error("Failed to fetch daily memories", error); message.error("Failed to load memory list"); } }; const handleFileClick = async (file: MarkdownFile) => { if (file.filename === "MEMORY.md") { if (expandedMemory && selectedFile?.filename === "MEMORY.md") { setExpandedMemory(false); return; } else { setExpandedMemory(true); fetchDailyMemories(); } } setSelectedFile(file); setLoading(true); try { // Use agent-specific API const data = await agentsApi.readAgentFile(selectedAgent, file.filename); setFileContent(data.content); setOriginalContent(data.content); } catch (error) { console.error("Failed to load file", error); message.error("Failed to load file"); } finally { setLoading(false); } }; const handleDailyMemoryClick = async (daily: DailyMemoryFile) => { setSelectedFile({ filename: `${daily.date}.md`, path: daily.path, size: daily.size, created_time: daily.created_time, modified_time: daily.modified_time, updated_at: daily.updated_at, }); setLoading(true); try { const data = await api.loadDailyMemory(daily.date); setFileContent(data.content); setOriginalContent(data.content); } catch (error) { console.error("Failed to load daily memory", error); message.error("Failed to load daily memory"); } finally { setLoading(false); } }; const handleSave = async () => { if (!selectedFile) return; setLoading(true); try { if (selectedFile.filename.match(/^\d{4}-\d{2}-\d{2}\.md$/)) { const date = selectedFile.filename.replace(".md", ""); await api.saveDailyMemory(date, fileContent); } else { await api.saveFile(selectedFile.filename, fileContent); } setOriginalContent(fileContent); message.success("Saved successfully"); if (selectedFile.filename.match(/^\d{4}-\d{2}-\d{2}\.md$/)) { fetchDailyMemories(); } else { fetchFiles(); } } catch (error) { console.error("Failed to save file", error); message.error("Failed to save"); } finally { setLoading(false); } }; const handleReset = () => { setFileContent(originalContent); }; const handleToggleFileEnabled = async (filename: string) => { const isEnabling = !enabledFiles.includes(filename); // Show warning for MEMORY.md if (isEnabling && filename === "MEMORY.md") { message.warning({ content: t("workspace.memoryFileWarning"), duration: 5, }); } const newEnabledFiles = enabledFiles.includes(filename) ? enabledFiles.filter((f) => f !== filename) : [...enabledFiles, filename]; try { await workspaceApi.setSystemPromptFiles(newEnabledFiles); setEnabledFiles(newEnabledFiles); message.success( t("workspace.configUpdated") || "System prompt configuration updated", ); } catch (error) { console.error("Failed to update system prompt files", error); message.error( t("workspace.configUpdateFailed") || "Failed to update system prompt configuration", ); } }; const handleReorderFiles = async (newOrder: string[]) => { try { await workspaceApi.setSystemPromptFiles(newOrder); setEnabledFiles(newOrder); } catch (error) { console.error("Failed to reorder files", error); message.error("Failed to update file order"); } }; const hasChanges = fileContent !== originalContent; return { files, selectedFile, dailyMemories, expandedMemory, fileContent, loading, workspacePath, hasChanges, enabledFiles, setFileContent, fetchFiles, fetchDailyMemories, handleFileClick, handleDailyMemoryClick, handleSave, handleReset, handleToggleFileEnabled, handleReorderFiles, }; }; ================================================ FILE: console/src/pages/Agent/Workspace/components/utils.ts ================================================ export const formatFileSize = (bytes: number): string => { if (bytes === 0) return "0 B"; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`; }; export const formatTimeAgo = (timestamp: number): string => { const seconds = Math.floor((Date.now() - timestamp) / 1000); if (seconds < 60) return "just now"; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; return `${Math.floor(seconds / 86400)}d ago`; }; export const isDailyMemoryFile = (filename: string): boolean => { return /^\d{4}-\d{2}-\d{2}\.md$/.test(filename); }; ================================================ FILE: console/src/pages/Agent/Workspace/index.module.less ================================================ .agentsPage { padding: 24px; height: calc(100vh - 96px); display: flex; flex-direction: column; overflow: hidden; } .header { flex-shrink: 0; } .title { margin-bottom: 4px; } .description { margin: 0; color: #999; font-size: 14px; } .content { display: flex; gap: 16px; flex: 1; min-height: 0; overflow: auto; padding-bottom: 12px; } .fileListPanel { width: 400px; flex-shrink: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; } .cardBody { padding: 16px; display: flex; flex-direction: column; height: 100%; overflow: auto; } .cardContainer { flex: 1; min-height: 0; } .workspaceInfo { display: flex; align-items: center; justify-content: space-between; height: 30px; } .headerRow { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-shrink: 0; } .actionButtons { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; } .sectionTitle { margin: 0; font-size: 14px; font-weight: 600; } .infoText { margin: 0; margin-bottom: 12px; font-size: 12px; color: #999; flex-shrink: 0; } .workspacePath { margin: 0; margin-bottom: 12px; font-size: 12px; color: #666; font-family: monospace; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .divider { height: 1px; background-color: #e8e8e8; margin-bottom: 16px; flex-shrink: 0; } .scrollContainer { flex: 1; min-height: 0; overflow: auto; &::-webkit-scrollbar { display: none; } scrollbar-width: none; -ms-overflow-style: none; -webkit-overflow-scrolling: touch; } .fileEditor { flex: 1; display: flex; flex-direction: column; min-height: 0; height: 100%; width: 100%; } .editorCard { height: 100% !important; :global { .copaw-card-body { height: 100%; .copaw-spark-card-wrapper { height: 100%; } .copaw-spark-content { height: 100%; } .agentscope-runtime-webui-spark-card-wrapper { height: 100%; .agentscope-runtime-webui-spark-content { height: 100%; } } .spark-card-wrapper { height: 100%; .spark-content { height: 100%; overflow: hidden; } } } } } .editorHeader { padding: 16px; border-bottom: 1px solid #f0f0f0; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; } .fileName { font-size: 16px; font-weight: 600; margin-bottom: 4px; } .filePath { font-size: 12px; color: #999; font-family: monospace; } .buttonGroup { display: flex; gap: 8px; align-items: center; } .editorContent { padding: 16px; height: calc(100% - 96px); textarea { font-family: monospace; font-size: 13px; resize: vertical; height: 100%; } } .contentLabel { margin-bottom: 8px; font-size: 12px; font-weight: 600; flex-shrink: 0; display: flex; justify-content: space-between; align-items: center; height: 20px; } .markdownViewer { flex: 1; display: flex; flex-direction: column; height: 100%; overflow: auto; border: 1px solid #e8e8e8; padding: 16px; border-radius: 8px; } .textarea { font-family: monospace; font-size: 13px; resize: vertical; height: min(100%, clamp(280px, 50vh, 640px)); min-height: min(240px, 100%); max-height: 100%; } .markdownToggle { display: flex; align-items: center; gap: 8px; } .toggleLabel { font-size: 12px; color: #666; white-space: nowrap; } .emptyState { flex: 1; display: flex; align-items: center; justify-content: center; color: #999; } .buttonGroup { display: flex; align-items: center; } .copyButton { border-radius: 4px; } .fileItem { padding: 12px; border: 1px solid #e8e8e8; border-radius: 8px; margin-bottom: 8px; cursor: pointer; background-color: #fff; transition: all 0.2s; &:hover:not(.selected) { border-color: #d9d9d9; background-color: #fafafa; } &.selected { border: 2px solid #615ced; background-color: #f6f5ff; } } .fileItemHeader { display: flex; align-items: center; justify-content: space-between; } .fileInfo { flex: 1; min-width: 0; } .fileItemActions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; margin-left: 12px; } .dragHandle { display: flex; align-items: center; justify-content: center; width: 20px; flex-shrink: 0; margin-right: 8px; color: #bbb; cursor: grab; font-size: 14px; transition: color 0.2s; &:active { cursor: grabbing; } &:hover { color: #615ced; } } .dragging { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); border-color: #615ced !important; background-color: #f6f5ff !important; } .enabledBadge { color: #52c41a; font-size: 8px; margin-right: 6px; vertical-align: middle; } .fileActions { display: flex; align-items: center; gap: 8px; } .fileItemName { font-size: 14px; font-weight: 600; margin-bottom: 4px; } .fileItemMeta { font-size: 12px; color: #999; } .expandIcon { font-size: 12px; color: #999; margin-left: 8px; } .dailyMemoryList { margin-left: 16px; margin-top: -4px; margin-bottom: 8px; } .dailyMemoryItem { padding: 10px; border: 1px solid #e8e8e8; border-radius: 6px; margin-bottom: 6px; cursor: pointer; background-color: #fff; transition: all 0.2s; &:hover:not(.selected) { border-color: #d9d9d9; background-color: #fafafa; } &.selected { border: 2px solid #615ced; background-color: #f6f5ff; } } .dailyMemoryName { font-size: 13px; font-weight: 500; margin-bottom: 2px; } .dailyMemoryMeta { font-size: 11px; color: #999; } .attribution { margin: 0; padding: 4px 0 0; font-size: 11px; color: #bbb; text-align: right; flex-shrink: 0; } /* ─── Dark mode ─────────────────────────────────────────────────────────────── */ :global(.dark-mode) { .description { color: rgba(255, 255, 255, 0.35); } .workspacePath { color: rgba(255, 255, 255, 0.3); } .infoText { color: rgba(255, 255, 255, 0.3); } .divider { background-color: rgba(255, 255, 255, 0.08); } /* File list items */ .fileItem { background-color: #2a2a2a; border-color: rgba(255, 255, 255, 0.1); &:hover:not(.selected) { border-color: rgba(255, 255, 255, 0.2); background-color: #333; } &.selected { border-color: #615ced; background-color: rgba(97, 92, 237, 0.15); } } .fileItemName { color: rgba(255, 255, 255, 0.85); } .fileItemMeta { color: rgba(255, 255, 255, 0.35); } .expandIcon { color: rgba(255, 255, 255, 0.3); } .dragHandle { color: rgba(255, 255, 255, 0.2); &:hover { color: #8b87f0; } } .dragging { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); border-color: #615ced !important; background-color: rgba(97, 92, 237, 0.15) !important; } /* Daily memory items */ .dailyMemoryItem { background-color: #2a2a2a; border-color: rgba(255, 255, 255, 0.1); &:hover:not(.selected) { border-color: rgba(255, 255, 255, 0.2); background-color: #333; } &.selected { border-color: #615ced; background-color: rgba(97, 92, 237, 0.15); } } .dailyMemoryName { color: rgba(255, 255, 255, 0.85); } .dailyMemoryMeta { color: rgba(255, 255, 255, 0.35); } /* Editor area */ .editorHeader { border-bottom-color: rgba(255, 255, 255, 0.08); } .fileName { color: rgba(255, 255, 255, 0.85); } .filePath { color: rgba(255, 255, 255, 0.35); } .markdownViewer { border-color: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.85); background: #1f1f1f; } .toggleLabel { color: rgba(255, 255, 255, 0.45); } .emptyState { color: rgba(255, 255, 255, 0.3); } .sectionTitle { color: rgba(255, 255, 255, 0.85); } .attribution { color: rgba(255, 255, 255, 0.2); } /* Textarea inside editor */ .textarea, .editorContent textarea { background: #2a2a2a !important; color: rgba(255, 255, 255, 0.85) !important; border-color: rgba(255, 255, 255, 0.1) !important; } } ================================================ FILE: console/src/pages/Agent/Workspace/index.tsx ================================================ import { useAgentsData, FileListPanel, FileEditor } from "./components"; import styles from "./index.module.less"; import { UploadOutlined, DownloadOutlined } from "@ant-design/icons"; import { Button, Tooltip, message } from "@agentscope-ai/design"; import { workspaceApi } from "../../../api/modules/workspace"; import { useRef } from "react"; import { useTranslation } from "react-i18next"; export default function WorkspacePage() { const { t } = useTranslation(); const { files, selectedFile, dailyMemories, expandedMemory, fileContent, loading, workspacePath, hasChanges, enabledFiles, setFileContent, fetchFiles, handleFileClick, handleDailyMemoryClick, handleSave, handleReset, handleToggleFileEnabled, handleReorderFiles, } = useAgentsData(); const fileInputRef = useRef(null); const handleDownload = async () => { try { const { blob, filename } = await workspaceApi.downloadWorkspace(); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); message.success(t("workspace.downloadSuccess")); } catch (error) { console.error("Download failed:", error); message.error( t("workspace.downloadFailed") + ": " + (error as Error).message, ); } }; const handleFileUpload = async ( event: React.ChangeEvent, ) => { const file = event.target.files?.[0]; if (!file) return; // Check if file is zip format if (!file.name.toLowerCase().endsWith(".zip")) { message.error(t("workspace.zipOnly")); if (fileInputRef.current) { fileInputRef.current.value = ""; } return; } const maxSize = 100 * 1024 * 1024; if (file.size > maxSize) { message.error( t("workspace.fileSizeExceeded", { size: (file.size / (1024 * 1024)).toFixed(2), }), ); if (fileInputRef.current) { fileInputRef.current.value = ""; } return; } try { const result = await workspaceApi.uploadFile(file); if (result.success) { message.success(t("workspace.uploadSuccess")); } else { message.error(t("workspace.uploadFailed") + ": " + result.message); } } catch (error) { console.error("Upload failed:", error); message.error( t("workspace.uploadFailed") + ": " + (error as Error).message, ); } finally { // Clear input value to allow re-uploading the same file if (fileInputRef.current) { fileInputRef.current.value = ""; } } }; const handleUploadClick = () => { fileInputRef.current?.click(); }; return (

{t("workspace.title")}

{t("workspace.workspacePath")}{" "} {workspacePath === null ? t("common.loading") : workspacePath || t("workspace.noFiles")}

{t("workspace.attribution")}

{/* Hidden file input - only accepts .zip files up to 100MB */}
); } ================================================ FILE: console/src/pages/Chat/ModelSelector/index.module.less ================================================ /* ---- Trigger button ---- */ .trigger { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 8px; cursor: pointer; font-size: 16px; font-weight: 600; color: #333; // border: 1px solid rgba(0, 0, 0, 0.08); // background: #fff; transition: all 0.15s ease; user-select: none; max-width: 260px; &:hover, &.triggerActive { border-color: #615ced; color: #615ced; box-shadow: 0 2px 8px rgba(97, 92, 237, 0.12); } } .triggerName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 180px; } .triggerArrow { flex-shrink: 0; font-size: 16px; color: #999; transition: transform 0.2s, color 0.15s; &.triggerArrowOpen { transform: rotate(180deg); color: #615ced; } } /* ---- Level-1 panel ---- */ .panel { min-width: 180px; border-radius: 12px; background: #fff; border: 1px solid rgba(0, 0, 0, 0.06); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); padding: 4px 0; overflow: visible; } .providerItem { position: relative; display: flex; align-items: center; justify-content: space-between; padding: 7px 12px; cursor: pointer; font-size: 16px; color: #333; transition: background 0.12s; gap: 8px; &:hover { background: rgba(97, 92, 237, 0.06); color: #615ced; } &:hover :global(.modelSubmenu) { display: block; } &:first-child { border-radius: 8px 8px 0 0; } &:last-child { border-radius: 0 0 8px 8px; } &.providerItemActive { font-weight: 600; } } .providerName { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .providerArrow { flex-shrink: 0; font-size: 16px; color: #bbb; } /* ---- Level-2 submenu ---- */ .submenu { display: none; position: absolute; top: -4px; left: 100%; margin-left: 2px; min-width: 200px; max-height: 360px; overflow-y: auto; border-radius: 12px; background: #fff; border: 1px solid rgba(0, 0, 0, 0.06); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); padding: 4px 0; z-index: 1100; } .modelItem { display: flex; align-items: center; justify-content: space-between; padding: 7px 12px; cursor: pointer; font-size: 16px; color: #333; transition: background 0.12s; gap: 8px; white-space: nowrap; &:hover { background: rgba(97, 92, 237, 0.06); color: #615ced; } &:first-child { border-radius: 8px 8px 0 0; } &:last-child { border-radius: 0 0 8px 8px; } &.modelItemActive { background: rgba(97, 92, 237, 0.08); color: #615ced; font-weight: 500; } } .modelName { overflow: hidden; text-overflow: ellipsis; flex: 1; } .checkIcon { flex-shrink: 0; font-size: 16px; color: #615ced; } .emptyTip { padding: 10px 14px; font-size: 16px; color: #bbb; } .spinWrapper { padding: 14px 16px; text-align: center; } /* ─── Dark mode overrides ─────────────────────────────────────────────────── */ :global(.dark-mode) { .trigger { color: rgba(255, 255, 255, 0.85); &:hover, &.triggerActive { color: #8b87f0; box-shadow: 0 2px 8px rgba(97, 92, 237, 0.2); } } .triggerArrow { color: rgba(255, 255, 255, 0.3); &.triggerArrowOpen { color: #8b87f0; } } .panel { background: #1f1f1f; border-color: rgba(255, 255, 255, 0.08); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); } .providerItem { color: rgba(255, 255, 255, 0.85); &:hover { background: rgba(97, 92, 237, 0.15); color: #8b87f0; } } .providerArrow { color: rgba(255, 255, 255, 0.2); } .submenu { background: #1f1f1f; border-color: rgba(255, 255, 255, 0.08); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); } .modelItem { color: rgba(255, 255, 255, 0.85); &:hover { background: rgba(97, 92, 237, 0.15); color: #8b87f0; } &.modelItemActive { background: rgba(97, 92, 237, 0.18); color: #8b87f0; } } .checkIcon { color: #8b87f0; } .emptyTip { color: rgba(255, 255, 255, 0.25); } } ================================================ FILE: console/src/pages/Chat/ModelSelector/index.tsx ================================================ import { useState, useEffect, useCallback, useRef } from "react"; import { Dropdown, message, Spin } from "antd"; import { DownOutlined, CheckOutlined, LoadingOutlined, RightOutlined, } from "@ant-design/icons"; import { useLocation } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { providerApi } from "../../../api/modules/provider"; import type { ProviderInfo, ActiveModelsInfo } from "../../../api/types"; import styles from "./index.module.less"; interface EligibleProvider { id: string; name: string; models: Array<{ id: string; name: string }>; } export default function ModelSelector() { const { t } = useTranslation(); const [providers, setProviders] = useState([]); const [activeModels, setActiveModels] = useState( null, ); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [open, setOpen] = useState(false); const savingRef = useRef(false); const location = useLocation(); const fetchData = useCallback(async () => { setLoading(true); try { const [provData, activeData] = await Promise.all([ providerApi.listProviders(), providerApi.getActiveModels(), ]); if (Array.isArray(provData)) setProviders(provData); if (activeData) setActiveModels(activeData); } catch (err) { console.error("ModelSelector: failed to load data", err); } finally { setLoading(false); } }, []); useEffect(() => { fetchData(); }, [fetchData]); // Re-sync active model whenever the route switches back to /chat const prevPathRef = useRef(location.pathname); useEffect(() => { const prev = prevPathRef.current; const curr = location.pathname; prevPathRef.current = curr; const comingToChat = curr.startsWith("/chat") && !prev.startsWith("/chat"); if (comingToChat) { providerApi .getActiveModels() .then((activeData) => { if (activeData) setActiveModels(activeData); }) .catch(() => {}); } }, [location.pathname]); // Eligible providers: configured + has models const eligibleProviders: EligibleProvider[] = providers .filter((p) => { const hasModels = (p.models?.length ?? 0) + (p.extra_models?.length ?? 0) > 0; if (!hasModels) return false; if (p.is_local) return true; if (p.require_api_key === false) return !!p.base_url; if (p.is_custom) return !!p.base_url; if (p.require_api_key ?? true) return !!p.api_key; return true; }) .map((p) => ({ id: p.id, name: p.name, models: [...(p.models ?? []), ...(p.extra_models ?? [])], })); const activeProviderId = activeModels?.active_llm?.provider_id; const activeModelId = activeModels?.active_llm?.model; // Display label for trigger button const activeModelName = (() => { if (!activeProviderId || !activeModelId) return t("modelSelector.selectModel"); for (const p of eligibleProviders) { if (p.id === activeProviderId) { const m = p.models.find((m) => m.id === activeModelId); if (m) return m.name || m.id; } } return activeModelId; })(); const handleOpenChange = useCallback(async (next: boolean) => { setOpen(next); if (next) { // Re-fetch active model every time the dropdown opens try { const activeData = await providerApi.getActiveModels(); if (activeData) setActiveModels(activeData); } catch { // ignore } } }, []); const handleSelect = async (providerId: string, modelId: string) => { if (savingRef.current) return; if (providerId === activeProviderId && modelId === activeModelId) { setOpen(false); return; } savingRef.current = true; setSaving(true); setOpen(false); try { await providerApi.setActiveLlm({ provider_id: providerId, model: modelId, }); setActiveModels({ active_llm: { provider_id: providerId, model: modelId }, }); } catch (err) { const msg = err instanceof Error ? err.message : t("modelSelector.switchFailed"); message.error(msg); } finally { setSaving(false); savingRef.current = false; } }; const dropdownContent = (
{loading ? (
) : eligibleProviders.length === 0 ? (
{t("modelSelector.noConfiguredModels")}
) : ( eligibleProviders.map((provider) => { const isProviderActive = provider.id === activeProviderId; return (
{provider.name} {/* Level-2 submenu — shown on parent hover via CSS */}
{provider.models.map((model) => { const isActive = isProviderActive && model.id === activeModelId; return (
{ e.stopPropagation(); handleSelect(provider.id, model.id); }} > {model.name || model.id} {isActive && ( )}
); })}
); }) )}
); return ( dropdownContent} trigger={["click"]} placement="bottomLeft" >
{saving && ( )} {activeModelName}
); } ================================================ FILE: console/src/pages/Chat/OptionsPanel/FormItem.tsx ================================================ import { Form } from "antd"; import { createStyles } from "antd-style"; interface FormItemProps { name: string | string[]; label: string; isList?: boolean; children: any; normalize?: (value: any) => any; } const useStyles = createStyles(({ token }) => ({ label: { marginBottom: 6, fontSize: 12, color: token.colorTextSecondary, }, })); export default function FormItem(props: FormItemProps) { const { styles } = useStyles(); const node = props.isList ? ( {props.children} ) : ( {props.children} ); return (
{props.label &&
{props.label}
} {node}
); } ================================================ FILE: console/src/pages/Chat/OptionsPanel/OptionsEditor.tsx ================================================ import React from "react"; import { Form, Input, ColorPicker, Flex, Divider, InputNumber } from "antd"; import { createStyles } from "antd-style"; import { Button, IconButton, Switch } from "@agentscope-ai/design"; import { SparkDeleteLine, SparkPlusLine } from "@agentscope-ai/icons"; import FormItem from "./FormItem"; import defaultConfig from "./defaultConfig"; const useStyles = createStyles(({ token }) => ({ container: { height: "100%", display: "flex", flexDirection: "column", }, form: { height: 0, flex: 1, padding: "8px 16px 16px 16px", overflow: "auto", }, actions: { padding: 16, display: "flex", borderTop: `1px solid ${token.colorBorderSecondary}`, justifyContent: "flex-end", gap: 16, }, })); interface OptionsEditorProps { value?: Record; onChange?: (value: Record) => void; } const OptionsEditor: React.FC = ({ value, onChange }) => { const { styles } = useStyles(); const [form] = Form.useForm(); const handleSave = () => { form .validateFields() .then((values) => { onChange?.(values); }) .catch((error) => { console.error("Validation failed:", error); }); }; const handleReset = () => { form.setFieldsValue(defaultConfig); }; return (
Theme value.toHexString()} > value.toHexString()} > value.toHexString()} > Sender Welcome {( fields: { key: string; name: string }[], { add, remove, }: { add: (item: any) => void; remove: (name: string) => void }, ) => { return (
{fields.map((field) => { return ( } onClick={() => add({})} > } onClick={() => remove(field.name)} > ); })}
); }}
API
); }; export default OptionsEditor; ================================================ FILE: console/src/pages/Chat/OptionsPanel/defaultConfig.ts ================================================ import type { TFunction } from "i18next"; const defaultConfig = { theme: { colorPrimary: "#615CED", darkMode: false, prefix: "copaw", leftHeader: { logo: "", title: "Work with CoPaw", }, }, sender: { attachments: true, maxLength: 10000, disclaimer: "Works for you, grows with you", }, welcome: { greeting: "Hello, how can I help you today?", description: "I am a helpful assistant that can help you with your questions.", avatar: `${import.meta.env.BASE_URL}copaw-symbol.svg`, prompts: [ { value: "Let's start a new journey!", }, { value: "Can you tell me what skills you have?", }, ], }, api: { baseURL: "", token: "", }, } as const; export function getDefaultConfig(t: TFunction) { return { ...defaultConfig, sender: { ...defaultConfig.sender, disclaimer: t("chat.disclaimer"), }, welcome: { ...defaultConfig.welcome, greeting: t("chat.greeting"), description: t("chat.description"), prompts: [{ value: t("chat.prompt1") }, { value: t("chat.prompt2") }], }, }; } export default defaultConfig; export type DefaultConfig = typeof defaultConfig; ================================================ FILE: console/src/pages/Chat/OptionsPanel/index.tsx ================================================ import { SparkSettingLine } from "@agentscope-ai/icons"; import { IconButton, Drawer } from "@agentscope-ai/design"; import { useState } from "react"; import OptionsEditor from "./OptionsEditor"; interface OptionsPanelProps { value?: Record; onChange?: (value: Record) => void; } export default function OptionsPanel(props: OptionsPanelProps) { const [open, setOpen] = useState(false); return ( <> setOpen(true)} icon={} bordered={false} /> setOpen(false)} styles={{ body: { padding: 0 }, header: { padding: 8 } }} > { setOpen(false); props.onChange?.(v); }} /> ); } ================================================ FILE: console/src/pages/Chat/index.module.less ================================================ /* Disabled input overlay */ .chatDisabledOverlay { position: relative; height: 100%; width: 100%; } .chatDisabledOverlay :global([class*="chat-anywhere-input"]), .chatDisabledOverlay :global([class*="chat-input"]), .chatDisabledOverlay :global(textarea), .chatDisabledOverlay :global(input[type="text"]) { pointer-events: none !important; opacity: 0.5 !important; background-color: #f5f5f5 !important; cursor: not-allowed !important; } .chatDisabledOverlay :global(button[class*="send"]), .chatDisabledOverlay :global(button[class*="submit"]) { pointer-events: none !important; opacity: 0.3 !important; cursor: not-allowed !important; } ================================================ FILE: console/src/pages/Chat/index.tsx ================================================ import { AgentScopeRuntimeWebUI, IAgentScopeRuntimeWebUIOptions, type IAgentScopeRuntimeWebUIMessage, type IAgentScopeRuntimeWebUIRef, Stream, } from "@agentscope-ai/chat"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button, Modal, Result, message } from "antd"; import { ExclamationCircleOutlined, SettingOutlined } from "@ant-design/icons"; import { SparkCopyLine } from "@agentscope-ai/icons"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; import sessionApi from "./sessionApi"; import defaultConfig, { getDefaultConfig } from "./OptionsPanel/defaultConfig"; import { chatApi } from "../../api/modules/chat"; import { getApiToken, getApiUrl } from "../../api/config"; import { providerApi } from "../../api/modules/provider"; import api from "../../api"; import ModelSelector from "./ModelSelector"; import { useTheme } from "../../contexts/ThemeContext"; import { useAgentStore } from "../../stores/agentStore"; import AgentScopeRuntimeResponseBuilder from "@agentscope-ai/chat/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Builder.js"; import { AgentScopeRuntimeRunStatus } from "@agentscope-ai/chat/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/types.js"; import { useChatAnywhereInput } from "@agentscope-ai/chat/lib/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereInputContext.js"; import "./index.module.less"; import { Tooltip } from "antd"; import { IconButton } from "@agentscope-ai/design"; import { SparkAttachmentLine } from "@agentscope-ai/icons"; type CopyableContent = { type?: string; text?: string; refusal?: string; }; type CopyableMessage = { role?: string; content?: string | CopyableContent[]; }; type CopyableResponse = { output?: CopyableMessage[]; }; type RuntimeUiMessage = IAgentScopeRuntimeWebUIMessage & { msgStatus?: string; role?: string; cards?: Array<{ code: string; data: unknown; }>; history?: boolean; }; type StreamResponseData = { status?: string; output?: Array<{ content?: unknown[]; }>; }; type RuntimeLoadingBridgeApi = { getLoading?: () => boolean | string; setLoading?: (loading: boolean | string) => void; }; interface CustomWindow extends Window { currentSessionId?: string; currentUserId?: string; currentChannel?: string; } declare const window: CustomWindow; function extractCopyableText(response: CopyableResponse): string { const collectText = (assistantOnly: boolean) => { const chunks = (response.output || []).flatMap((item: CopyableMessage) => { if (assistantOnly && item.role !== "assistant") return []; if (typeof item.content === "string") { return [item.content]; } if (!Array.isArray(item.content)) { return []; } return item.content.flatMap((content: CopyableContent) => { if (content.type === "text" && typeof content.text === "string") { return [content.text]; } if (content.type === "refusal" && typeof content.refusal === "string") { return [content.refusal]; } return []; }); }); return chunks.filter(Boolean).join("\n\n").trim(); }; return collectText(true) || JSON.stringify(response); } async function copyText(text: string) { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return; } const textarea = document.createElement("textarea"); textarea.value = text; textarea.setAttribute("readonly", ""); textarea.style.position = "absolute"; textarea.style.left = "-9999px"; document.body.appendChild(textarea); let copied = false; try { textarea.focus(); textarea.select(); copied = document.execCommand("copy"); } finally { document.body.removeChild(textarea); } if (!copied) { throw new Error("Failed to copy text"); } } function buildModelError(): Response { return new Response( JSON.stringify({ error: "Model not configured", message: "Please configure a model first", }), { status: 400, headers: { "Content-Type": "application/json" } }, ); } function cloneRuntimeMessages( messages: RuntimeUiMessage[], ): RuntimeUiMessage[] { return JSON.parse(JSON.stringify(messages)) as RuntimeUiMessage[]; } function cloneValue(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } function isFinalResponseStatus(status?: string): boolean { return ( status === AgentScopeRuntimeRunStatus.Completed || status === AgentScopeRuntimeRunStatus.Failed || status === AgentScopeRuntimeRunStatus.Canceled ); } function hasRenderableOutput(response: StreamResponseData): boolean { if (response.status === AgentScopeRuntimeRunStatus.Failed) { return true; } return ( response.output?.some((message) => (message.content?.length ?? 0) > 0) ?? false ); } function getResponseCardData( message?: RuntimeUiMessage, ): StreamResponseData | null { const responseCard = message?.cards?.find( (card) => card.code === "AgentScopeRuntimeResponseCard", ); if (!responseCard?.data) { return null; } return cloneValue(responseCard.data as StreamResponseData); } function getStreamingAssistantMessageId( messages: RuntimeUiMessage[], ): string | null { return ( [...messages] .reverse() .find( (message) => message.role === "assistant" && (message.msgStatus === "generating" || (message.cards?.length ?? 0) === 0), )?.id || [...messages].reverse().find((message) => message.role === "assistant") ?.id || null ); } function RuntimeLoadingBridge({ bridgeRef, }: { bridgeRef: { current: RuntimeLoadingBridgeApi | null }; }) { const { setLoading, getLoading } = useChatAnywhereInput( (value) => ({ setLoading: value.setLoading, getLoading: value.getLoading, }) as RuntimeLoadingBridgeApi, ); useEffect(() => { if (!setLoading || !getLoading) { bridgeRef.current = null; return; } bridgeRef.current = { setLoading, getLoading, }; return () => { if (bridgeRef.current?.setLoading === setLoading) { bridgeRef.current = null; } }; }, [getLoading, setLoading, bridgeRef]); return null; } export default function ChatPage() { const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); const { isDark } = useTheme(); const chatId = useMemo(() => { const match = location.pathname.match(/^\/chat\/(.+)$/); return match?.[1]; }, [location.pathname]); const [showModelPrompt, setShowModelPrompt] = useState(false); const { selectedAgent } = useAgentStore(); const [refreshKey, setRefreshKey] = useState(0); const [chatStatus, setChatStatus] = useState<"idle" | "running">("idle"); const [, setReconnectStreaming] = useState(false); const reconnectTriggeredForRef = useRef(null); const prevChatIdRef = useRef(undefined); const runtimeLoadingBridgeRef = useRef(null); const isComposingRef = useRef(false); const isChatActiveRef = useRef(false); isChatActiveRef.current = location.pathname === "/" || location.pathname.startsWith("/chat"); const lastSessionIdRef = useRef(null); const chatIdRef = useRef(chatId); const navigateRef = useRef(navigate); const chatRef = useRef(null); chatIdRef.current = chatId; navigateRef.current = navigate; useEffect(() => { sessionApi.setChatRef(chatRef); return () => sessionApi.setChatRef(null); }, []); useEffect(() => { const handleCompositionStart = () => { if (!isChatActiveRef.current) return; isComposingRef.current = true; }; const handleCompositionEnd = () => { if (!isChatActiveRef.current) return; setTimeout(() => { isComposingRef.current = false; }, 150); }; const handleKeyPress = (e: KeyboardEvent) => { if (!isChatActiveRef.current) return; const target = e.target as HTMLElement; if (target?.tagName === "TEXTAREA" && e.key === "Enter" && !e.shiftKey) { if (isComposingRef.current || (e as any).isComposing) { e.stopPropagation(); e.stopImmediatePropagation(); return false; } } }; document.addEventListener("compositionstart", handleCompositionStart, true); document.addEventListener("compositionend", handleCompositionEnd, true); document.addEventListener("keypress", handleKeyPress, true); return () => { document.removeEventListener( "compositionstart", handleCompositionStart, true, ); document.removeEventListener( "compositionend", handleCompositionEnd, true, ); document.removeEventListener("keypress", handleKeyPress, true); }; }, []); useEffect(() => { sessionApi.onSessionIdResolved = (tempId, realId) => { if (!isChatActiveRef.current) return; if (chatIdRef.current === tempId) { lastSessionIdRef.current = realId; navigateRef.current(`/chat/${realId}`, { replace: true }); } }; sessionApi.onSessionRemoved = (removedId) => { if (!isChatActiveRef.current) return; if (chatIdRef.current === removedId) { lastSessionIdRef.current = null; navigateRef.current("/chat", { replace: true }); } }; return () => { sessionApi.onSessionIdResolved = null; sessionApi.onSessionRemoved = null; }; }, []); // Fetch chat status when viewing a chat (for running indicator and reconnect) useEffect(() => { if (!chatId || chatId === "undefined" || chatId === "null") { setChatStatus("idle"); return; } const realId = sessionApi.getRealIdForSession(chatId) ?? chatId; api.getChat(realId).then( (res) => setChatStatus((res.status as "idle" | "running") ?? "idle"), () => setChatStatus("idle"), ); }, [chatId]); // Trigger reconnect when session status becomes "running" so the library // consumes the SSE stream. Done here (not in sessionApi.getSession) so we // run after React has updated and the chat input ref is ready, avoiding // a fixed timeout and race conditions. useEffect(() => { if (prevChatIdRef.current !== chatId) { prevChatIdRef.current = chatId; reconnectTriggeredForRef.current = null; } if (!chatId || chatStatus !== "running") return; if (reconnectTriggeredForRef.current === chatId) return; reconnectTriggeredForRef.current = chatId; sessionApi.triggerReconnectSubmit(); }, [chatId, chatStatus]); // Refresh chat when selectedAgent changes const prevSelectedAgentRef = useRef(selectedAgent); useEffect(() => { // Only refresh if selectedAgent actually changed (not initial mount) if ( prevSelectedAgentRef.current !== selectedAgent && prevSelectedAgentRef.current !== undefined ) { // Force re-render by updating refresh key setRefreshKey((prev) => prev + 1); } prevSelectedAgentRef.current = selectedAgent; }, [selectedAgent]); const getSessionListWrapped = useCallback(async () => { const sessions = await sessionApi.getSessionList(); const currentChatId = chatIdRef.current; if (currentChatId) { const idx = sessions.findIndex((s) => s.id === currentChatId); if (idx > 0) { return [ sessions[idx], ...sessions.slice(0, idx), ...sessions.slice(idx + 1), ]; } } return sessions; }, []); const getSessionWrapped = useCallback(async (sessionId: string) => { const currentChatId = chatIdRef.current; if ( isChatActiveRef.current && sessionId && sessionId !== lastSessionIdRef.current && sessionId !== currentChatId ) { const urlId = sessionApi.getRealIdForSession(sessionId) ?? sessionId; lastSessionIdRef.current = urlId; navigateRef.current(`/chat/${urlId}`, { replace: true }); } return sessionApi.getSession(sessionId); }, []); const createSessionWrapped = useCallback(async (session: any) => { const result = await sessionApi.createSession(session); const newSessionId = session?.id || result[0]?.id; if (isChatActiveRef.current && newSessionId) { lastSessionIdRef.current = newSessionId; navigateRef.current(`/chat/${newSessionId}`, { replace: true }); } return result; }, []); const wrappedSessionApi = useMemo( () => ({ getSessionList: getSessionListWrapped, getSession: getSessionWrapped, createSession: createSessionWrapped, updateSession: sessionApi.updateSession.bind(sessionApi), removeSession: sessionApi.removeSession.bind(sessionApi), }), [], ); const copyResponse = useCallback( async (response: CopyableResponse) => { try { await copyText(extractCopyableText(response)); message.success(t("common.copied")); } catch { message.error(t("common.copyFailed")); } }, [t], ); const persistSessionMessages = useCallback( async (sessionId: string, messages: RuntimeUiMessage[]) => { if (!sessionId) return; await sessionApi.updateSession({ id: sessionId, messages: cloneRuntimeMessages(messages), }); }, [], ); const releaseStaleLoadingState = useCallback((sessionId: string) => { const activeChatId = chatIdRef.current; const realSessionId = sessionApi.getRealIdForSession(sessionId); const isBackgroundSession = activeChatId !== sessionId && activeChatId !== realSessionId; if (!isBackgroundSession) { return; } if (sessionApi.hasLiveMessagesForSession(activeChatId)) { return; } runtimeLoadingBridgeRef.current?.setLoading?.(false); }, []); const persistStreamSession = useCallback( (sessionId: string, readableStream: ReadableStream) => { const initialMessages = cloneRuntimeMessages( (chatRef.current?.messages.getMessages() as RuntimeUiMessage[]) || [], ); const assistantMessageId = getStreamingAssistantMessageId(initialMessages) || `stream-${sessionId}`; const responseBuilder = new AgentScopeRuntimeResponseBuilder({ id: "", status: AgentScopeRuntimeRunStatus.Created, created_at: 0, }); void (async () => { let cachedMessages = initialMessages; let hasStreamActivity = false; let didReleaseLoading = false; try { for await (const chunk of Stream({ readableStream })) { let chunkData: unknown; try { chunkData = JSON.parse(chunk.data); } catch { continue; } hasStreamActivity = true; const responseData = responseBuilder.handle( chunkData as never, ) as StreamResponseData; const isFinalChunk = isFinalResponseStatus(responseData.status); const existingAssistantMessage = cachedMessages.find( (message) => message.id === assistantMessageId, ); const previousResponseData = getResponseCardData( existingAssistantMessage, ); let nextResponseData: StreamResponseData | null = null; if (hasRenderableOutput(responseData)) { nextResponseData = cloneValue(responseData); } else if (isFinalChunk && previousResponseData) { nextResponseData = { ...previousResponseData, status: responseData.status ?? previousResponseData.status, }; } if (nextResponseData) { const assistantMessage: RuntimeUiMessage = { ...(existingAssistantMessage || { id: assistantMessageId, role: "assistant", }), id: assistantMessageId, role: "assistant", cards: [ { code: "AgentScopeRuntimeResponseCard", data: nextResponseData, }, ], msgStatus: isFinalChunk ? "finished" : "generating", }; const assistantIndex = cachedMessages.findIndex( (message) => message.id === assistantMessageId, ); cachedMessages = assistantIndex >= 0 ? [ ...cachedMessages.slice(0, assistantIndex), assistantMessage, ...cachedMessages.slice(assistantIndex + 1), ] : [...cachedMessages, assistantMessage]; await persistSessionMessages(sessionId, cachedMessages); } if (!isFinalChunk) { continue; } releaseStaleLoadingState(sessionId); didReleaseLoading = true; } } catch (error) { console.error("Failed to persist background chat stream:", error); } finally { if (!hasStreamActivity || didReleaseLoading) { return; } releaseStaleLoadingState(sessionId); } })(); }, [persistSessionMessages, releaseStaleLoadingState], ); const customFetch = useCallback( async (data: { input?: any[]; biz_params?: any; signal?: AbortSignal; reconnect?: boolean; session_id?: string; user_id?: string; channel?: string; }): Promise => { const headers: Record = { "Content-Type": "application/json", }; const token = getApiToken(); if (token) headers.Authorization = `Bearer ${token}`; try { const agentStorage = localStorage.getItem("copaw-agent-storage"); if (agentStorage) { const parsed = JSON.parse(agentStorage); const selectedAgent = parsed?.state?.selectedAgent; if (selectedAgent) { headers["X-Agent-Id"] = selectedAgent; } } } catch (error) { console.warn("Failed to get selected agent from storage:", error); } const shouldReconnect = data.reconnect || data.biz_params?.reconnect === true; const reconnectSessionId = data.session_id ?? window.currentSessionId ?? ""; if (shouldReconnect && reconnectSessionId) { const res = await fetch(getApiUrl("/console/chat"), { method: "POST", headers, body: JSON.stringify({ reconnect: true, session_id: reconnectSessionId, user_id: data.user_id ?? window.currentUserId ?? "default", channel: data.channel ?? window.currentChannel ?? "console", }), }); if (!res.ok || !res.body) return res; const onStreamEnd = () => { setChatStatus("idle"); setReconnectStreaming(false); }; const stream = res.body; const transformed = new ReadableStream({ start(controller) { const reader = stream.getReader(); function pump() { reader.read().then(({ done, value }) => { if (done) { controller.close(); onStreamEnd(); return; } controller.enqueue(value); return pump(); }); } pump(); }, }); return new Response(transformed, { headers: res.headers, status: res.status, }); } try { const activeModels = await providerApi.getActiveModels(); if ( !activeModels?.active_llm?.provider_id || !activeModels?.active_llm?.model ) { setShowModelPrompt(true); return buildModelError(); } } catch { setShowModelPrompt(true); return buildModelError(); } const { input = [], biz_params } = data; const session = input[input.length - 1]?.session || {}; const lastInput = input.slice(-1); const lastMsg = lastInput[0]; const rewrittenInput = lastMsg?.content && Array.isArray(lastMsg.content) ? [ { ...lastMsg, content: lastMsg.content.map((part: any) => { const p = { ...part }; const toStoredName = (v: string) => { const m1 = v.match(/\/console\/files\/[^/]+\/(.+)$/); if (m1) return m1[1]; const m2 = v.match(/^[^/]+\/(.+)$/); if (m2) return m2[1]; return v; }; if (p.type === "image" && typeof p.image_url === "string") p.image_url = toStoredName(p.image_url); if (p.type === "file" && typeof p.file_url === "string") p.file_url = toStoredName(p.file_url); if (p.type === "audio" && typeof p.audio_url === "string") p["data"] = toStoredName(p.audio_url); if (p.type === "video" && typeof p.video_url === "string") p.video_url = toStoredName(p.video_url); return p; }), }, ] : lastInput; const requestBody = { input: rewrittenInput, session_id: window.currentSessionId || session?.session_id || "", user_id: window.currentUserId || session?.user_id || "default", channel: window.currentChannel || session?.channel || "console", stream: true, ...biz_params, }; const response = await fetch(getApiUrl("/console/chat"), { method: "POST", headers, body: JSON.stringify(requestBody), signal: data.signal, }); if (!response.ok || !response.body || !requestBody.session_id) { return response; } const [uiStream, cacheStream] = response.body.tee(); persistStreamSession(requestBody.session_id, cacheStream); return new Response(uiStream, { status: response.status, statusText: response.statusText, headers: response.headers, }); }, [persistStreamSession, setChatStatus, setReconnectStreaming], ); const options = useMemo(() => { const i18nConfig = getDefaultConfig(t); const handleBeforeSubmit = async () => { if (isComposingRef.current) return false; return true; }; return { ...i18nConfig, theme: { ...defaultConfig.theme, darkMode: isDark, leftHeader: { ...defaultConfig.theme.leftHeader, }, rightHeader: ( <> ), }, welcome: { ...i18nConfig.welcome, avatar: isDark ? `${import.meta.env.BASE_URL}copaw-dark.png` : `${import.meta.env.BASE_URL}copaw-symbol.svg`, }, sender: { ...(i18nConfig as any)?.sender, beforeSubmit: handleBeforeSubmit, attachments: { trigger: function (props: any) { return ( } bordered={false} /> ); }, accept: "*/*", customRequest: async (options: { file: File; onSuccess: (body: { url?: string; thumbUrl?: string }) => void; onError?: (e: Error) => void; onProgress?: (e: { percent?: number }) => void; }) => { try { console.log("options.file", options.file); // Check file size limit (10MB) const file = options.file as File; const isLt10M = file.size / 1024 / 1024 < 10; if (!isLt10M) { message.error(t("chat.attachments.fileSizeLimit")); return options.onError?.(new Error("File size exceeds 10MB")); } options.onProgress?.({ percent: 0 }); const res = await chatApi.uploadFile(options.file); options.onProgress?.({ percent: 100 }); options.onSuccess({ url: chatApi.fileUrl(res.url) }); } catch (e) { options.onError?.(e instanceof Error ? e : new Error(String(e))); } }, }, }, session: { multiple: true, api: wrappedSessionApi }, api: { ...defaultConfig.api, fetch: customFetch, cancel(data: { session_id: string }) { const chatIdForStop = data?.session_id ? sessionApi.getRealIdForSession(data.session_id) ?? data.session_id : ""; if (chatIdForStop) { chatApi.stopConsoleChat(chatIdForStop).then( () => setChatStatus("idle"), (err) => { console.error("stopConsoleChat failed:", err); }, ); } }, }, actions: { list: [ { icon: ( ), onClick: ({ data }: { data: CopyableResponse }) => { void copyResponse(data); }, }, ], replace: true, }, } as unknown as IAgentScopeRuntimeWebUIOptions; }, [wrappedSessionApi, customFetch, copyResponse, t, isDark]); return (
} title={ {t("modelConfig.promptTitle")} } subTitle={ {t("modelConfig.promptMessage")} } extra={[ , , ]} />
); } ================================================ FILE: console/src/pages/Chat/sessionApi/index.ts ================================================ import type { RefObject } from "react"; import { IAgentScopeRuntimeWebUISession, IAgentScopeRuntimeWebUISessionAPI, IAgentScopeRuntimeWebUIMessage, IAgentScopeRuntimeWebUIRef, IAgentScopeRuntimeWebUIInputData, } from "@agentscope-ai/chat"; import api, { type ChatSpec, type Message } from "../../../api"; import { chatApi } from "../../../api/modules/chat"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const DEFAULT_USER_ID = "default"; const DEFAULT_CHANNEL = "console"; const DEFAULT_SESSION_NAME = "New Chat"; const ROLE_TOOL = "tool"; const ROLE_USER = "user"; const ROLE_ASSISTANT = "assistant"; const TYPE_PLUGIN_CALL_OUTPUT = "plugin_call_output"; // const CARD_REQUEST = "AgentScopeRuntimeRequestCard"; const CARD_RESPONSE = "AgentScopeRuntimeResponseCard"; // --------------------------------------------------------------------------- // Window globals // --------------------------------------------------------------------------- interface CustomWindow extends Window { currentSessionId?: string; currentUserId?: string; currentChannel?: string; } declare const window: CustomWindow; // --------------------------------------------------------------------------- // Local helper types // --------------------------------------------------------------------------- /** A single item inside a message's content array. */ interface ContentItem { type: string; text?: string; [key: string]: unknown; } /** A backend message after role-normalisation (output of toOutputMessage). */ interface OutputMessage extends Omit { role: string; metadata: null; sequence_number?: number; } /** * Extended session carrying extra fields that the library type does not define * but our backend / window globals require. */ interface ExtendedSession extends IAgentScopeRuntimeWebUISession { sessionId: string; userId: string; channel: string; meta: Record; /** Real backend UUID, used when id is overridden with a local timestamp. */ realId?: string; /** Conversation status: idle or running (for reconnect). */ status?: "idle" | "running"; } interface RuntimeResponseCard { data?: { status?: string; }; } const LIVE_MESSAGE_STATUSES = new Set(["generating", "created", "in_progress"]); const hasLiveMessages = ( messages?: IAgentScopeRuntimeWebUIMessage[], ): boolean => { if (!messages?.length) return false; return messages.some((message) => { const msgStatus = typeof (message as { msgStatus?: unknown }).msgStatus === "string" ? ((message as { msgStatus: string }).msgStatus as string) : undefined; if (msgStatus && LIVE_MESSAGE_STATUSES.has(msgStatus)) { return true; } const cards = (message as { cards?: RuntimeResponseCard[] }).cards; return ( cards?.some((card) => { const status = card.data?.status; return !!status && LIVE_MESSAGE_STATUSES.has(status); }) ?? false ); }); }; const hasMessages = (messages?: IAgentScopeRuntimeWebUIMessage[]): boolean => (messages?.length ?? 0) > 0; const shouldPreferLocalMessages = ( localMessages?: IAgentScopeRuntimeWebUIMessage[], remoteMessages?: IAgentScopeRuntimeWebUIMessage[], ): boolean => { if (!hasMessages(localMessages)) { return false; } if (!hasMessages(remoteMessages)) { return true; } return (remoteMessages?.length ?? 0) < (localMessages?.length ?? 0); }; // --------------------------------------------------------------------------- // Message conversion helpers: backend flat messages → card-based UI format // --------------------------------------------------------------------------- function generateId(): string { return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } /** Turn a backend content URL (path or full URL) into a full URL for display. */ function toDisplayUrl(url: string | undefined): string { if (!url) return ""; if (url.startsWith("http://") || url.startsWith("https://")) return url; return chatApi.fileUrl(url.startsWith("/") ? url : `/${url}`); } /** Map backend message content to request card content (text + image + file). */ function contentToRequestParts( content: unknown, ): Array> { if (typeof content === "string") { return [{ type: "text", text: content, status: "created" }]; } if (!Array.isArray(content)) { return [{ type: "text", text: String(content || ""), status: "created" }]; } const parts: Array> = []; for (const c of content as ContentItem[]) { if (c.type === "text") { if (c.text) parts.push({ type: "text", text: c.text, status: "created" }); } else if (c.type === "image" && c.image_url) { parts.push({ type: "image", image_url: toDisplayUrl(c.image_url as string), status: "created", }); } else if (c.type === "file" && (c.file_url || c.file_id)) { parts.push({ type: "file", file_url: toDisplayUrl((c.file_url as string) || (c.file_id as string)), file_name: (c.filename as string) || (c.file_name as string) || "file", status: "created", }); } } if (parts.length === 0) { parts.push({ type: "text", text: "", status: "created" }); } return parts; } /** * Convert a backend message to a response output message. * Maps system + plugin_call_output → role "tool" and strips metadata. */ const toOutputMessage = (msg: Message): OutputMessage => ({ ...msg, role: msg.type === TYPE_PLUGIN_CALL_OUTPUT && msg.role === "system" ? ROLE_TOOL : msg.role, metadata: null, }); /** Build a user card (AgentScopeRuntimeRequestCard) from a user message. */ function buildUserCard(msg: Message): IAgentScopeRuntimeWebUIMessage { const contentParts = contentToRequestParts(msg.content); return { id: (msg.id as string) || generateId(), role: "user", cards: [ { code: "AgentScopeRuntimeRequestCard", data: { input: [ { role: "user", type: "message", content: contentParts, }, ], }, }, ], }; } /** * Build an assistant response card (AgentScopeRuntimeResponseCard) * wrapping a group of consecutive non-user output messages. */ const buildResponseCard = ( outputMessages: OutputMessage[], ): IAgentScopeRuntimeWebUIMessage => { const now = Math.floor(Date.now() / 1000); const maxSeq = outputMessages.reduce( (max, m) => Math.max(max, m.sequence_number || 0), 0, ); return { id: generateId(), role: ROLE_ASSISTANT, cards: [ { code: CARD_RESPONSE, data: { id: `response_${generateId()}`, output: outputMessages, object: "response", status: "completed", created_at: now, sequence_number: maxSeq + 1, error: null, completed_at: now, usage: null, }, }, ], msgStatus: "finished", }; }; /** * Convert flat backend messages into the card-based format expected by * the @agentscope-ai/chat component. * * - User messages → AgentScopeRuntimeRequestCard * - Consecutive non-user messages (assistant / system / tool) → grouped * into a single AgentScopeRuntimeResponseCard with all output messages. */ const convertMessages = ( messages: Message[], ): IAgentScopeRuntimeWebUIMessage[] => { const result: IAgentScopeRuntimeWebUIMessage[] = []; let i = 0; while (i < messages.length) { if (messages[i].role === ROLE_USER) { result.push(buildUserCard(messages[i++])); } else { const outputMsgs: OutputMessage[] = []; while (i < messages.length && messages[i].role !== ROLE_USER) { outputMsgs.push(toOutputMessage(messages[i++])); } if (outputMsgs.length) result.push(buildResponseCard(outputMsgs)); } } return result; }; const chatSpecToSession = (chat: ChatSpec): ExtendedSession => ({ id: chat.id, name: (chat as ChatSpec & { name?: string }).name || DEFAULT_SESSION_NAME, sessionId: chat.session_id, userId: chat.user_id, channel: chat.channel, messages: [], meta: chat.meta || {}, status: chat.status ?? "idle", }) as ExtendedSession; /** Returns true when id is a pure numeric local timestamp (not a backend UUID). */ const isLocalTimestamp = (id: string): boolean => /^\d+$/.test(id); /** * Resolve and persist the real backend UUID for a local timestamp session. * Stores the real UUID as realId while keeping the timestamp as id, so the * library's internal currentSessionId (timestamp) remains valid. * Returns the resolved real UUID, or null if not found. */ const resolveRealId = ( sessionList: IAgentScopeRuntimeWebUISession[], tempSessionId: string, ): { list: IAgentScopeRuntimeWebUISession[]; realId: string | null } => { const realSession = sessionList.find( (s) => (s as ExtendedSession).sessionId === tempSessionId, ); if (!realSession) return { list: sessionList, realId: null }; const realUUID = realSession.id; // Keep the timestamp as id (so the library's currentSessionId still matches), // and store the real UUID in realId for backend requests. (realSession as ExtendedSession).realId = realUUID; realSession.id = tempSessionId; return { list: [realSession, ...sessionList.filter((s) => s !== realSession)], realId: realUUID, }; }; // --------------------------------------------------------------------------- // SessionApi // --------------------------------------------------------------------------- class SessionApi implements IAgentScopeRuntimeWebUISessionAPI { private sessionList: IAgentScopeRuntimeWebUISession[] = []; private resolvingSessionIds: Set = new Set(); private findSessionIndexByAnyId(sessionId?: string | null): number { if (!sessionId) { return -1; } return this.sessionList.findIndex((session) => { const extended = session as ExtendedSession; return ( extended.id === sessionId || extended.realId === sessionId || extended.sessionId === sessionId ); }); } private findSessionByAnyId( sessionId?: string | null, ): ExtendedSession | null { const index = this.findSessionIndexByAnyId(sessionId); return index >= 0 ? (this.sessionList[index] as ExtendedSession) : null; } private cacheSession(session: ExtendedSession): void { const index = this.findSessionIndexByAnyId(session.id); if (index >= 0) { const existing = this.sessionList[index] as ExtendedSession; this.sessionList[index] = { ...existing, ...session, id: existing.id, realId: session.realId ?? existing.realId, } as ExtendedSession; return; } this.sessionList.unshift(session); } /** * Deduplicates concurrent getSessionList calls so that two parallel * invocations share one network request and write sessionList only once, * preserving any realId mappings that were already resolved. */ private sessionListRequest: Promise | null = null; /** * Deduplicates concurrent getSession calls for the same sessionId. * Key: sessionId, Value: in-flight promise for getSession. */ private sessionRequests: Map< string, Promise > = new Map(); /** * Called when a temporary timestamp session id is resolved to a real backend * UUID. Consumers (e.g. Chat/index.tsx) can register here to update the URL. */ onSessionIdResolved: ((tempId: string, realId: string) => void) | null = null; /** * Called after a session is removed. Consumers can register here to clear * the session id from the URL. */ onSessionRemoved: ((removedId: string) => void) | null = null; /** * Ref to the chat component so we can trigger submit with reconnect flag * (library will call customFetch with biz_params.reconnect and consume the SSE stream). */ private chatRef: RefObject | null = null; setChatRef(ref: RefObject | null): void { this.chatRef = ref; } /** * Programmatically trigger the library's submit with biz_params.reconnect so * customFetch does POST /console/chat with reconnect:true and the library * consumes the SSE stream (replay + live tail). */ triggerReconnectSubmit(): void { const ref = this.chatRef?.current; if (!ref?.input?.submit) { console.warn("triggerReconnectSubmit: chatRef not available"); return; } ref.input.submit({ query: "", biz_params: { reconnect: true, } as IAgentScopeRuntimeWebUIInputData["biz_params"], }); } private resolveRealIdInBackground(tempId: string): void { if (this.resolvingSessionIds.has(tempId)) { return; } this.resolvingSessionIds.add(tempId); void this.getSessionList() .then(() => { const { list, realId } = resolveRealId(this.sessionList, tempId); this.sessionList = list; if (realId) { this.onSessionIdResolved?.(tempId, realId); } }) .finally(() => { this.resolvingSessionIds.delete(tempId); }); } private createEmptySession(sessionId: string): ExtendedSession { window.currentSessionId = sessionId; window.currentUserId = DEFAULT_USER_ID; window.currentChannel = DEFAULT_CHANNEL; return { id: sessionId, name: DEFAULT_SESSION_NAME, sessionId, userId: DEFAULT_USER_ID, channel: DEFAULT_CHANNEL, messages: [], meta: {}, } as ExtendedSession; } private updateWindowVariables(session: ExtendedSession): void { window.currentSessionId = session.sessionId || ""; window.currentUserId = session.userId || DEFAULT_USER_ID; window.currentChannel = session.channel || DEFAULT_CHANNEL; } private getLocalSession(sessionId: string): IAgentScopeRuntimeWebUISession { const local = this.sessionList.find((s) => s.id === sessionId); if (local) { this.updateWindowVariables(local as ExtendedSession); return local; } return this.createEmptySession(sessionId); } /** * Returns the real backend UUID for a session identified by id (which may be * a local timestamp). Returns null when not yet resolved or not found. */ getRealIdForSession(sessionId: string): string | null { const s = this.findSessionByAnyId(sessionId); return s?.realId ?? null; } hasLiveMessagesForSession(sessionId?: string | null): boolean { return hasLiveMessages(this.findSessionByAnyId(sessionId)?.messages); } async getSessionList() { // Deduplicate: reuse the in-flight request if one is already running so // concurrent calls don't overwrite sessionList and lose realId mappings. if (this.sessionListRequest) return this.sessionListRequest; this.sessionListRequest = (async () => { try { const previousSessions = [...this.sessionList] as ExtendedSession[]; const chats = await api.listChats(); const newList = chats .filter((c) => c.id && c.id !== "undefined" && c.id !== "null") .map(chatSpecToSession) .reverse(); // Merge: preserve realId mappings (timestamp → UUID) stored in memory const mergedSessions = newList.map((s) => { const existing = previousSessions.find( (e) => (e as ExtendedSession).sessionId === (s as ExtendedSession).sessionId, ) as ExtendedSession | undefined; if (!existing) { return s; } return { ...s, id: existing.realId ? existing.id : s.id, name: existing.name && existing.name !== DEFAULT_SESSION_NAME ? existing.name : s.name, messages: hasMessages(existing.messages) ? existing.messages : s.messages, meta: Object.keys(existing.meta || {}).length > 0 ? existing.meta : s.meta, realId: existing.realId, } as ExtendedSession; }); const preservedLocalSessions = previousSessions.filter((session) => { if (!isLocalTimestamp(session.id) || session.realId) { return false; } return !mergedSessions.some( (merged) => (merged as ExtendedSession).sessionId === session.sessionId, ); }); this.sessionList = [...preservedLocalSessions, ...mergedSessions]; return [...this.sessionList]; } finally { this.sessionListRequest = null; } })(); return this.sessionListRequest; } async getSession(sessionId: string) { // Deduplicate: reuse the in-flight request if one is already running // for the same sessionId so concurrent calls share one network request. const existingRequest = this.sessionRequests.get(sessionId); if (existingRequest) return existingRequest; const requestPromise = this._doGetSession(sessionId); this.sessionRequests.set(sessionId, requestPromise); try { const result = await requestPromise; // Reconnect for running sessions is triggered by ChatPage when session // status becomes "running" (useEffect on chatStatus), avoiding a fixed // timeout and race conditions with the chat input ref. return result; } finally { this.sessionRequests.delete(sessionId); } } private async _doGetSession( sessionId: string, ): Promise { // --- Local timestamp ID (New Chat before first reply) --- if (isLocalTimestamp(sessionId)) { const fromList = this.sessionList.find((s) => s.id === sessionId) as | ExtendedSession | undefined; if (fromList && hasMessages(fromList.messages)) { this.updateWindowVariables(fromList); return fromList; } // If realId is already resolved, use it directly to fetch history. if (fromList?.realId) { const chatHistory = await api.getChat(fromList.realId); const remoteMessages = convertMessages(chatHistory.messages || []); if (shouldPreferLocalMessages(fromList.messages, remoteMessages)) { this.updateWindowVariables(fromList); return fromList; } const session: ExtendedSession = { id: sessionId, name: fromList.name || DEFAULT_SESSION_NAME, sessionId: fromList.sessionId || sessionId, userId: fromList.userId || DEFAULT_USER_ID, channel: fromList.channel || DEFAULT_CHANNEL, messages: remoteMessages, meta: fromList.meta || {}, realId: fromList.realId, status: chatHistory.status ?? "idle", }; this.cacheSession(session); this.updateWindowVariables(session); return session; } return this.getLocalSession(sessionId); } // --- No session selected (e.g. after delete) --- // Return a transient empty session; it is NOT added to sessionList so it // never appears as a list item. The component will call createSession on // the next submit via ensureSession. if (!sessionId || sessionId === "undefined" || sessionId === "null") { return this.createEmptySession(Date.now().toString()); } // --- Regular backend UUID --- const fromList = this.sessionList.find((s) => s.id === sessionId) as | ExtendedSession | undefined; if (fromList && hasMessages(fromList.messages)) { this.updateWindowVariables(fromList); return fromList; } const chatHistory = await api.getChat(sessionId); const remoteMessages = convertMessages(chatHistory.messages || []); if ( fromList && shouldPreferLocalMessages(fromList.messages, remoteMessages) ) { this.updateWindowVariables(fromList); return fromList; } const session: ExtendedSession = { id: sessionId, name: fromList?.name || sessionId, sessionId: fromList?.sessionId || sessionId, userId: fromList?.userId || DEFAULT_USER_ID, channel: fromList?.channel || DEFAULT_CHANNEL, messages: remoteMessages, meta: fromList?.meta || {}, status: chatHistory.status ?? "idle", }; this.cacheSession(session); this.updateWindowVariables(session); return session; } async updateSession(session: Partial) { if (!session.id) { return [...this.sessionList]; } const index = this.findSessionIndexByAnyId(session.id); if (index > -1) { const existing = this.sessionList[index] as ExtendedSession; this.sessionList[index] = { ...existing, ...session, id: existing.id, sessionId: existing.sessionId || session.id, userId: existing.userId || window.currentUserId || DEFAULT_USER_ID, channel: existing.channel || window.currentChannel || DEFAULT_CHANNEL, messages: session.messages ?? existing.messages, meta: existing.meta || {}, } as ExtendedSession; // Timestamp session without realId yet — resolve in the background if (isLocalTimestamp(existing.id) && !existing.realId) { this.resolveRealIdInBackground(existing.id); } } else { const nextSession: ExtendedSession = { id: session.id, name: session.name ?? DEFAULT_SESSION_NAME, sessionId: session.id, userId: window.currentUserId || DEFAULT_USER_ID, channel: window.currentChannel || DEFAULT_CHANNEL, messages: session.messages ?? [], meta: {}, }; this.sessionList.unshift(nextSession); if (isLocalTimestamp(session.id)) { this.resolveRealIdInBackground(session.id); } } return [...this.sessionList]; } async createSession(session: Partial) { session.id = Date.now().toString(); const extended: ExtendedSession = { ...session, name: session.name || DEFAULT_SESSION_NAME, messages: session.messages ?? [], sessionId: session.id, userId: DEFAULT_USER_ID, channel: DEFAULT_CHANNEL, } as ExtendedSession; this.updateWindowVariables(extended); return [...this.sessionList]; } async removeSession(session: Partial) { if (!session.id) return [...this.sessionList]; const { id: sessionId } = session; const existing = this.sessionList.find((s) => s.id === sessionId) as | ExtendedSession | undefined; // Use realId (UUID) when available; skip backend call for pure local sessions const deleteId = existing?.realId ?? (isLocalTimestamp(sessionId) ? null : sessionId); if (deleteId) await api.deleteChat(deleteId); this.sessionList = this.sessionList.filter((s) => s.id !== sessionId); // Notify consumers (e.g. to clear the URL) with both the list id and the // real backend UUID so callers can match either form. const resolvedId = existing?.realId ?? sessionId; this.onSessionRemoved?.(resolvedId); return [...this.sessionList]; } } export default new SessionApi(); ================================================ FILE: console/src/pages/Control/Channels/components/ChannelCard.tsx ================================================ import { Card, Tooltip } from "@agentscope-ai/design"; import { useTranslation } from "react-i18next"; import { getChannelLabel, type ChannelKey } from "./constants"; import styles from "../index.module.less"; interface ChannelCardProps { channelKey: ChannelKey; config: Record; isHover: boolean; onClick: () => void; onMouseEnter: () => void; onMouseLeave: () => void; } export function ChannelCard({ channelKey, config, isHover, onClick, onMouseEnter, onMouseLeave, }: ChannelCardProps) { const { t } = useTranslation(); const enabled = Boolean(config.enabled); const isBuiltin = Boolean(config.isBuiltin); const label = getChannelLabel(channelKey); const getConfigString = (key: string) => typeof config[key] === "string" ? config[key] : ""; const phoneNumber = getConfigString("phone_number"); const botPrefix = getConfigString("bot_prefix"); const getCardClassNames = () => { if (isHover) return `${styles.channelCard} ${styles.hover}`; if (enabled) return `${styles.channelCard} ${styles.enabled}`; return `${styles.channelCard} ${styles.normal}`; }; return (
{label}
{isBuiltin ? ( {t("channels.builtin")} ) : ( {t("channels.custom")} )}
{enabled ? t("common.enabled") : t("common.disabled")}
{channelKey === "voice" ? ( <> {t("channels.phoneNumber")}: {phoneNumber || t("channels.notSet")} ) : ( <> {t("channels.botPrefix")}: {botPrefix || t("channels.notSet")} )}
{t("channels.clickCardToEdit")}
); } ================================================ FILE: console/src/pages/Control/Channels/components/ChannelDrawer.tsx ================================================ import { Drawer, Form, Input, InputNumber, Switch, Button, Select, message, } from "@agentscope-ai/design"; import { Alert, ConfigProvider } from "antd"; import { LinkOutlined } from "@ant-design/icons"; import { useTranslation } from "react-i18next"; import type { FormInstance } from "antd"; import { useCallback, useRef } from "react"; import { getChannelLabel, type ChannelKey } from "./constants"; import styles from "../index.module.less"; import { useTheme } from "../../../../contexts/ThemeContext"; const WECOM_SDK_URL = "https://wwcdn.weixin.qq.com/node/wework/js/wecom-aibot-sdk@0.1.0.min.js"; const WECOM_SOURCE = "copaw"; interface WecomBotInfo { botid: string; secret: string; } interface WecomAuthError { code: string; message: string; details?: unknown; } declare global { interface Window { WecomAIBotSDK?: { openBotInfoAuthWindow: (options: { source: string; onCreated?: (bot: WecomBotInfo) => void; onError?: (error: WecomAuthError) => void; }) => Promise | void; }; } } const CHANNELS_WITH_ACCESS_CONTROL: ChannelKey[] = [ "telegram", "dingtalk", "discord", "feishu", "wecom", "mattermost", "matrix", ]; // Doc EN URLs per channel (anchors on https://copaw.agentscope.io/docs/channels) const CHANNEL_DOC_EN_URLS: Partial> = { dingtalk: "https://copaw.agentscope.io/docs/channels/?lang=en#DingTalk-recommended", feishu: "https://copaw.agentscope.io/docs/channels/?lang=en#Feishu-Lark", imessage: "https://copaw.agentscope.io/docs/channels/?lang=en#iMessage-macOS-only", discord: "https://copaw.agentscope.io/docs/channels/?lang=en#Discord", qq: "https://copaw.agentscope.io/docs/channels/?lang=en#QQ", telegram: "https://copaw.agentscope.io/docs/channels/?lang=en#Telegram", mqtt: "https://copaw.agentscope.io/docs/channels/?lang=en#MQTT", mattermost: "https://copaw.agentscope.io/docs/channels/?lang=en#Mattermost", matrix: "https://copaw.agentscope.io/docs/channels/?lang=en#Matrix", wecom: "https://copaw.agentscope.io/docs/channels/?lang=en#WeCom-WeChat-Work", xiaoyi: "https://developer.huawei.com/consumer/cn/doc/service/openclaw-0000002518410344", }; // Doc ZH URLs per channel (anchors on https://copaw.agentscope.io/docs/channels) const CHANNEL_DOC_ZH_URLS: Partial> = { dingtalk: "https://copaw.agentscope.io/docs/channels/?lang=zh#钉钉推荐", feishu: "https://copaw.agentscope.io/docs/channels/?lang=zh#飞书", imessage: "https://copaw.agentscope.io/docs/channels/?lang=zh#iMessage仅-macOS", discord: "https://copaw.agentscope.io/docs/channels/?lang=zh#Discord", qq: "https://copaw.agentscope.io/docs/channels/?lang=zh#QQ", telegram: "https://copaw.agentscope.io/docs/channels/?lang=zh#Telegram", mqtt: "https://copaw.agentscope.io/docs/channels/?lang=zh#MQTT", mattermost: "https://copaw.agentscope.io/docs/channels/?lang=zh#Mattermost", matrix: "https://copaw.agentscope.io/docs/channels/?lang=zh#Matrix", wecom: "https://copaw.agentscope.io/docs/channels/?lang=zh#企业微信", xiaoyi: "https://developer.huawei.com/consumer/cn/doc/service/openclaw-0000002518410344", }; const TWILIO_CONSOLE_URL = "https://console.twilio.com"; const BASE_FIELDS = [ "enabled", "bot_prefix", "filter_tool_messages", "filter_thinking", "isBuiltin", ]; interface ChannelDrawerProps { open: boolean; activeKey: ChannelKey | null; activeLabel: string; form: FormInstance>; saving: boolean; initialValues: Record | undefined; isBuiltin: boolean; onClose: () => void; onSubmit: (values: Record) => void; } export function ChannelDrawer({ open, activeKey, activeLabel, form, saving, initialValues, isBuiltin, onClose, onSubmit, }: ChannelDrawerProps) { const { t, i18n } = useTranslation(); const { isDark } = useTheme(); const currentLang = i18n.language?.startsWith("zh") ? "zh" : "en"; const label = activeKey ? getChannelLabel(activeKey) : activeLabel; const sdkLoadedRef = useRef(false); // Dynamically load the WeCom SDK script const loadWecomSDK = useCallback((): Promise => { return new Promise((resolve, reject) => { if (window.WecomAIBotSDK || sdkLoadedRef.current) { resolve(); return; } const script = document.createElement("script"); script.src = WECOM_SDK_URL; script.async = true; script.onload = () => { sdkLoadedRef.current = true; resolve(); }; script.onerror = () => reject(new Error("Failed to load WeCom SDK")); document.body.appendChild(script); }); }, []); // Handle WeCom scan-to-authorize button click; source is fixed to WECOM_SOURCE const handleWecomAuth = useCallback(async () => { try { await loadWecomSDK(); } catch { message.error(t("channels.wecomSdkLoadFailed")); return; } if (!window.WecomAIBotSDK) { message.error(t("channels.wecomSdkLoadFailed")); return; } const result = window.WecomAIBotSDK.openBotInfoAuthWindow({ source: WECOM_SOURCE, }); if (result && typeof result.then === "function") { result.then( (bot) => { if (bot?.botid) { form.setFieldsValue({ bot_id: bot.botid, secret: bot.secret }); message.success(t("channels.wecomAuthSuccess")); } }, (error: WecomAuthError) => { if (error?.code === "WINDOW_BLOCKED") { message.error(t("channels.wecomWindowBlocked")); } else if (error?.code === "CANCELLED") { message.info(t("channels.wecomCancelled")); } else { message.error( t("channels.wecomAuthFailed", { msg: error?.message || error?.code || "Unknown error", }), ); } }, ); } }, [loadWecomSDK, form, t]); // ── Access control fields (shared across multiple channels) ────────────── const renderAccessControlFields = () => ( <> ); case "imessage": return ( <> ); case "discord": return ( <> ); case "dingtalk": return ( <> ); }} ); case "feishu": return ( <> ); case "qq": return ( <> ); case "telegram": return ( <> ); case "mqtt": return ( <> ); case "mattermost": return ( <> ); case "voice": return ( <> ); case "wecom": return ( <> {t("channels.wecomAuthHint")} ); case "xiaoyi": return ( <> ); default: return null; } }; // ── Custom channel fields (key-value editor) ───────────────────────────── const renderCustomExtraFields = ( values: Record | undefined, ) => { if (!values) return null; const extraKeys = Object.keys(values).filter( (k) => !BASE_FIELDS.includes(k), ); if (extraKeys.length === 0) return null; return ( <>
Custom Fields
{extraKeys.map((fieldKey) => { const value = values[fieldKey]; return ( {typeof value === "boolean" ? ( ) : typeof value === "number" ? ( ) : ( )} ); })} ); }; // ── Drawer title ───────────────────────────────────────────────────────── const drawerTitle = (
{label ? `${label} ${t("channels.settings")}` : t("channels.channelSettings")} {activeKey && CHANNEL_DOC_EN_URLS[activeKey] && CHANNEL_DOC_ZH_URLS[activeKey] && ( )} {activeKey === "voice" && ( )}
); // ── Render ─────────────────────────────────────────────────────────────── const drawerFooter = (
); return ( {activeKey && (
{activeKey !== "voice" && ( )} {activeKey !== "console" && ( <> )} {isBuiltin ? renderBuiltinExtraFields(activeKey) : renderCustomExtraFields(initialValues)} {CHANNELS_WITH_ACCESS_CONTROL.includes(activeKey) && renderAccessControlFields()}
)}
); } ================================================ FILE: console/src/pages/Control/Channels/components/constants.ts ================================================ // Channel key type - now accepts any string for custom channels export type ChannelKey = string; // Built-in channel labels export const CHANNEL_LABELS: Record = { imessage: "iMessage", discord: "Discord", dingtalk: "DingTalk", feishu: "Feishu", qq: "QQ", telegram: "Telegram", mqtt: "MQTT", mattermost: "Mattermost", matrix: "Matrix", console: "Console", voice: "Twilio", wecom: "WeCom", xiaoyi: "XiaoYi", }; // Get channel label - returns built-in label or formatted custom name export function getChannelLabel(key: string): string { if (CHANNEL_LABELS[key]) { return CHANNEL_LABELS[key]; } // Format custom channel name: my_channel -> My Channel return key .split(/[_-]/) .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); } ================================================ FILE: console/src/pages/Control/Channels/components/index.ts ================================================ export { ChannelCard } from "./ChannelCard"; export { ChannelDrawer } from "./ChannelDrawer"; export { useChannels } from "../useChannels"; export { CHANNEL_LABELS, getChannelLabel, type ChannelKey } from "./constants"; ================================================ FILE: console/src/pages/Control/Channels/index.module.less ================================================ .channelsPage { padding: 24px; } .pageHeader { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; } .title { margin-bottom: 4px; font-size: 24px; font-weight: 600; } .description { color: #999; font-size: 14px; margin: 0; } .filterTabs { display: flex; gap: 4px; padding: 4px; background: rgba(0, 0, 0, 0.04); border-radius: 10px; align-self: center; } .filterTab { padding: 5px 14px; border-radius: 7px; border: none; background: transparent; font-size: 13px; color: #666; cursor: pointer; transition: all 0.18s ease; &:hover { color: #333; background: rgba(0, 0, 0, 0.04); } } .filterTabActive { background: #fff; color: #615ced; font-weight: 500; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); } .loading { text-align: center; padding: 60px; } .loadingText { color: #999; } .channelsGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 24px; } /* ---- Channel Card Styles ---- */ .channelCard { border-radius: 16px; cursor: pointer; transition: all 0.2s ease-in-out; &.hover { border: 2px solid #615ced; box-shadow: 0 12px 32px rgba(97, 92, 237, 0.3); } &.enabled { border: 2px solid #615ced; box-shadow: 0 8px 24px rgba(97, 92, 237, 0.2); transform: scale(1.02); } &.normal { border: 1px solid rgba(0, 0, 0, 0.04); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04); transform: scale(1); } } .cardHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .cardHeaderRight { display: flex; align-items: center; gap: 8px; } .cardTitleRow { display: flex; align-items: center; gap: 6px; } .builtinTag { font-size: 10px; color: #615ced; background: rgba(97, 92, 237, 0.1); border-radius: 4px; padding: 1px 6px; white-space: nowrap; flex-shrink: 0; } .customTag { font-size: 10px; color: #fa8c16; background: rgba(250, 140, 22, 0.1); border: none; border-radius: 4px; padding: 1px 6px; margin: 0; white-space: nowrap; flex-shrink: 0; } .statusContainer { display: flex; align-items: center; justify-content: center; gap: 8px; font-size: 12px; color: #999; } .statusDot { width: 8px; height: 8px; border-radius: 50%; &.enabled { background-color: #52c41a; box-shadow: 0 0 0 2px rgba(82, 196, 26, 0.2); } &.disabled { background-color: #d9d9d9; box-shadow: none; } } .channelTag { font-size: 12px; color: #b37feb; padding: 2px 8px; border-radius: 999px; background: rgba(97, 92, 237, 0.08); } .cardTitle { max-width: 150px; font-size: 16px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 0; } .cardDescription { font-size: 12px; color: #999; margin-bottom: 12px; } .cardFooter { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; } .actionButton { padding: 0; height: auto; font-size: 12px; } .cardHint { font-size: 12px; color: #bbb; } /* ---- Channel Drawer Styles ---- */ .drawerTitle { display: flex; align-items: center; gap: 8px; } .dingtalkDocBtn { font-size: 12px; padding: 0 8px; } .formActions { display: flex; justify-content: flex-end; gap: 8px; } .formTopActions { display: flex; justify-content: flex-end; gap: 8px; margin-bottom: 16px; } .drawerHeaderActions { display: flex; gap: 8px; align-items: center; } /* ─── Dark mode ─────────────────────────────────────────────────────────────── */ :global(.dark-mode) { .title { color: rgba(255, 255, 255, 0.85); } .description { color: rgba(255, 255, 255, 0.35); } .filterTabs { background: rgba(255, 255, 255, 0.06); } .filterTab { color: rgba(255, 255, 255, 0.45); &:hover { color: rgba(255, 255, 255, 0.75); background: rgba(255, 255, 255, 0.06); } } .filterTabActive { background: #2a2a2a; color: #8b87f0; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); } .loadingText { color: rgba(255, 255, 255, 0.35); } /* Card background and text */ .channelCard { &.normal { border-color: rgba(255, 255, 255, 0.08); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); } } .cardTitle { color: rgba(255, 255, 255, 0.85); } .statusContainer { color: rgba(255, 255, 255, 0.35); } .statusDot.disabled { background-color: rgba(255, 255, 255, 0.2); } .cardDescription { color: rgba(255, 255, 255, 0.35); } .cardHint { color: rgba(255, 255, 255, 0.2); } .drawerHeaderActions { .cancelBtn { color: rgba(255, 255, 255, 0.65); } } } ================================================ FILE: console/src/pages/Control/Channels/index.tsx ================================================ import { useMemo, useState } from "react"; import { Form, message } from "@agentscope-ai/design"; import { useTranslation } from "react-i18next"; import api from "../../../api"; import { ChannelCard, ChannelDrawer, useChannels, getChannelLabel, type ChannelKey, } from "./components"; import styles from "./index.module.less"; type FilterType = "all" | "builtin" | "custom"; function ChannelsPage() { const { t } = useTranslation(); const { channels, orderedKeys, isBuiltin, loading, fetchChannels } = useChannels(); const [filter, setFilter] = useState("all"); const [saving, setSaving] = useState(false); const [hoverKey, setHoverKey] = useState(null); const [activeKey, setActiveKey] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); // eslint-disable-next-line @typescript-eslint/no-explicit-any const [form] = Form.useForm(); // Sort cards: enabled first, then disabled (preserve orderedKeys order within each group) const cards = useMemo(() => { const enabledCards: { key: ChannelKey; config: Record }[] = []; const disabledCards: { key: ChannelKey; config: Record; }[] = []; orderedKeys.forEach((key) => { const config = channels[key] || { enabled: false, bot_prefix: "" }; const builtin = isBuiltin(key); if (filter === "builtin" && !builtin) return; if (filter === "custom" && builtin) return; if (config.enabled) { enabledCards.push({ key, config }); } else { disabledCards.push({ key, config }); } }); return [...enabledCards, ...disabledCards]; }, [channels, orderedKeys, filter, isBuiltin]); const handleCardClick = (key: ChannelKey) => { setActiveKey(key); setDrawerOpen(true); const channelConfig = channels[key] || { enabled: false, bot_prefix: "" }; form.setFieldsValue({ ...channelConfig, filter_tool_messages: !channelConfig.filter_tool_messages, filter_thinking: !channelConfig.filter_thinking, }); }; const handleDrawerClose = () => { setDrawerOpen(false); setActiveKey(null); }; const handleSubmit = async (values: Record) => { if (!activeKey) return; // eslint-disable-next-line @typescript-eslint/no-unused-vars const { isBuiltin: _isBuiltin, ...savedConfig } = channels[activeKey] || {}; const updatedChannel: Record = { ...savedConfig, ...values, filter_tool_messages: !values.filter_tool_messages, filter_thinking: !values.filter_thinking, }; setSaving(true); try { await api.updateChannelConfig( activeKey, updatedChannel as unknown as Parameters< typeof api.updateChannelConfig >[1], ); await fetchChannels(); setDrawerOpen(false); message.success(t("channels.configSaved")); } catch (error) { console.error("❌ Failed to update channel config:", error); message.error(t("channels.configFailed")); } finally { setSaving(false); } }; const activeLabel = activeKey ? getChannelLabel(activeKey) : ""; const FILTER_TABS: { key: FilterType; label: string }[] = [ { key: "all", label: t("channels.filterAll") }, { key: "builtin", label: t("channels.builtin") }, { key: "custom", label: t("channels.custom") }, ]; return (

{t("channels.title")}

{t("channels.description")}

{FILTER_TABS.map(({ key, label }) => ( ))}
{loading ? (
{t("channels.loading")}
) : (
{cards.map(({ key, config }) => ( handleCardClick(key)} onMouseEnter={() => setHoverKey(key)} onMouseLeave={() => setHoverKey(null)} /> ))}
)}
); } export default ChannelsPage; ================================================ FILE: console/src/pages/Control/Channels/useChannels.ts ================================================ import { useState, useEffect, useCallback, useMemo } from "react"; import api from "../../../api"; import { useAgentStore } from "../../../stores/agentStore"; export function useChannels() { const { selectedAgent } = useAgentStore(); const [channels, setChannels] = useState< Record> >({}); const [channelTypes, setChannelTypes] = useState([]); const [loading, setLoading] = useState(true); const fetchChannels = useCallback(async () => { setLoading(true); try { const [data, types] = await Promise.all([ api.listChannels(), api.listChannelTypes(), ]); if (data) setChannels(data as unknown as Record>); if (types) setChannelTypes(types); } catch (error) { console.error("❌ Failed to load channels:", error); } finally { setLoading(false); } }, []); useEffect(() => { fetchChannels(); }, [fetchChannels, selectedAgent]); // Built-in channels come first (in a fixed order), then custom channels const builtinOrder = useMemo( () => [ "console", "dingtalk", "feishu", "imessage", "discord", "telegram", "qq", "matrix", "xiaoyi", ], [], ); const orderedKeys = useMemo( () => [ ...builtinOrder.filter((k) => channelTypes.includes(k)), ...channelTypes.filter((k) => !builtinOrder.includes(k)), ], [builtinOrder, channelTypes], ); // Read isBuiltin from API response const isBuiltin = useCallback( (key: string) => Boolean(channels[key]?.isBuiltin), [channels], ); return { channels, channelTypes, orderedKeys, isBuiltin, loading, fetchChannels, }; } ================================================ FILE: console/src/pages/Control/CronJobs/components/JobDrawer.tsx ================================================ import { Drawer, Form, Input, InputNumber, Select, Switch, Button, Checkbox, } from "@agentscope-ai/design"; import { TimePicker } from "antd"; import { useTranslation } from "react-i18next"; import type { FormInstance } from "antd"; import type { CronJobSpecOutput } from "../../../../api/types"; import { TIMEZONE_OPTIONS, DEFAULT_FORM_VALUES } from "./constants"; import styles from "../../CronJobs/index.module.less"; type CronJob = CronJobSpecOutput; interface JobDrawerProps { open: boolean; editingJob: CronJob | null; form: FormInstance; saving: boolean; onClose: () => void; onSubmit: (values: CronJob) => void; } export function JobDrawer({ open, editingJob, form, saving, onClose, onSubmit, }: JobDrawerProps) { const { t } = useTranslation(); return (
} >
prev.cronType !== cur.cronType} > {({ getFieldValue }) => { const cronType = getFieldValue("cronType"); if (cronType === "daily" || cronType === "weekly") { return ( ); } return null; }} prev.cronType !== cur.cronType} > {({ getFieldValue }) => { const cronType = getFieldValue("cronType"); if (cronType === "weekly") { return ( ); } return null; }} prev.cronType !== cur.cronType} > {({ getFieldValue }) => { const cronType = getFieldValue("cronType"); if (cronType === "custom") { return (
{t("cronJobs.cronExample")}
{t("cronJobs.cronHelper")}{" "} {t("cronJobs.cronHelperLink")} →
} > ); } return null; }} text agent { if (!value) return Promise.resolve(); try { JSON.parse(value); return Promise.resolve(); } catch { return Promise.reject( new Error(t("cronJobs.invalidJsonFormat")), ); } }, }, ]} tooltip={t("cronJobs.requestInputTooltip")} extra={ {t("cronJobs.requestInputExample")} } > ); } ================================================ FILE: console/src/pages/Control/CronJobs/components/columns.tsx ================================================ import { Button, Tooltip, Dropdown } from "@agentscope-ai/design"; import type { ColumnsType } from "antd/es/table"; import type { MenuProps } from "antd"; import type { CronJobSpecOutput } from "../../../../api/types"; import { CopyOutlined, MoreOutlined } from "@ant-design/icons"; import { message } from "antd"; import { TFunction } from "i18next"; import { parseCron } from "./parseCron"; type CronJob = CronJobSpecOutput; interface ColumnHandlers { onToggleEnabled: (job: CronJob) => void; onExecuteNow: (job: CronJob) => void; onEdit: (job: CronJob) => void; onDelete: (jobId: string) => void; t: TFunction; } const createCopyToClipboard = (t: TFunction) => async (text: string) => { try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); message.success(t("common.copied")); } else { const textArea = document.createElement("textarea"); textArea.value = text; textArea.style.position = "fixed"; textArea.style.left = "-999999px"; textArea.style.top = "-999999px"; document.body.appendChild(textArea); textArea.focus(); textArea.select(); document.execCommand("copy"); textArea.remove(); message.success(t("common.copied")); } } catch (err) { console.error("Failed to copy text: ", err); message.error(t("common.copyFailed")); } }; export const createColumns = ( handlers: ColumnHandlers, ): ColumnsType => { const copyToClipboard = createCopyToClipboard(handlers.t); return [ { title: handlers.t("cronJobs.id"), dataIndex: "id", key: "id", width: 250, fixed: "left", }, { title: handlers.t("cronJobs.name"), dataIndex: "name", key: "name", width: 250, }, { title: handlers.t("cronJobs.enabled"), dataIndex: "enabled", key: "enabled", width: 100, render: (enabled: boolean) => ( {enabled ? handlers.t("common.enabled") : handlers.t("common.disabled")} ), }, { title: handlers.t("cronJobs.scheduleType"), dataIndex: ["schedule", "type"], key: "schedule_type", width: 140, render: () => "cron", }, { title: handlers.t("cronJobs.scheduleCron"), dataIndex: ["schedule", "cron"], key: "cron", width: 180, render: (cron: string) => { // Parse cron to friendly text const cronParts = parseCron(cron || "0 9 * * *"); let displayText = ""; switch (cronParts.type) { case "hourly": displayText = handlers.t("cronJobs.cronTypeHourly"); break; case "daily": displayText = `${handlers.t("cronJobs.cronTypeDaily")} ${String( cronParts.hour, ).padStart(2, "0")}:${String(cronParts.minute).padStart(2, "0")}`; break; case "weekly": { const dayNames = (cronParts.daysOfWeek || []) .map((d) => { const dayMap: Record = { mon: handlers.t("cronJobs.cronDayMon"), tue: handlers.t("cronJobs.cronDayTue"), wed: handlers.t("cronJobs.cronDayWed"), thu: handlers.t("cronJobs.cronDayThu"), fri: handlers.t("cronJobs.cronDayFri"), sat: handlers.t("cronJobs.cronDaySat"), sun: handlers.t("cronJobs.cronDaySun"), }; return dayMap[d] || d; }) .join(","); displayText = `${handlers.t( "cronJobs.cronTypeWeekly", )} ${dayNames} ${String(cronParts.hour).padStart(2, "0")}:${String( cronParts.minute, ).padStart(2, "0")}`; break; } case "custom": displayText = cron; break; } return (
Cron 表达式: {cron}
格式: 分钟 小时 日 月 星期
} > {displayText}
); }, }, { title: handlers.t("cronJobs.scheduleTimezone"), dataIndex: ["schedule", "timezone"], key: "timezone", width: 170, }, { title: "TaskType", dataIndex: "task_type", key: "task_type", width: 140, }, { title: handlers.t("cronJobs.taskText"), dataIndex: "text", key: "text", width: 200, ellipsis: { showTitle: true, }, render: (text: string) => { if (!text) return "-"; return ( {text} ); }, }, { title: "RequestInput", dataIndex: ["request", "input"], key: "request_input", width: 350, ellipsis: true, render: (input: unknown) => { if (!input) return "-"; let displayText: string; let fullText: string; try { fullText = JSON.stringify(input, null, 2); displayText = JSON.stringify(input); } catch { fullText = String(input); displayText = fullText; } if (displayText.length <= 50) { return {displayText}; } const truncatedText = displayText.length > 50 ? displayText.substring(0, 50) + "..." : displayText; return (
{fullText}
t("cronJobs.totalItems", { count: total }), }} /> ); } export default CronJobsPage; ================================================ FILE: console/src/pages/Control/CronJobs/useCronJobs.ts ================================================ import { useState, useEffect } from "react"; import { message } from "@agentscope-ai/design"; import api from "../../../api"; import type { CronJobSpecOutput } from "../../../api/types"; import { useAgentStore } from "../../../stores/agentStore"; type CronJob = CronJobSpecOutput; export function useCronJobs() { const { selectedAgent } = useAgentStore(); const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(false); const fetchJobs = async () => { setLoading(true); try { const data = await api.listCronJobs(); if (data) { setJobs(data as CronJob[]); } } catch (error) { console.error("Failed to load cron jobs", error); message.error("Failed to load Cron Jobs"); } finally { setLoading(false); } }; useEffect(() => { let mounted = true; const loadJobs = async () => { await fetchJobs(); }; if (mounted) { loadJobs(); } return () => { mounted = false; }; }, [selectedAgent]); const createJob = async (values: CronJob) => { try { const created = await api.createCronJob(values); setJobs((prev) => [created as CronJob, ...prev]); message.success("Created successfully"); return true; } catch (error) { console.error("Failed to create cron job", error); message.error("Failed to save"); return false; } }; const updateJob = async (jobId: string, values: CronJob) => { const original = jobs.find((j) => j.id === jobId); const optimisticUpdate = { ...original, ...values }; setJobs((prev) => prev.map((j) => (j.id === jobId ? optimisticUpdate : j))); try { const updated = await api.replaceCronJob(jobId, values); setJobs((prev) => prev.map((j) => (j.id === jobId ? (updated as CronJob) : j)), ); message.success("Updated successfully"); return true; } catch (error) { console.error("Failed to update cron job", error); if (original) { setJobs((prev) => prev.map((j) => (j.id === jobId ? original : j))); } message.error("Failed to save"); return false; } }; const deleteJob = async (jobId: string) => { const original = jobs.find((j) => j.id === jobId); setJobs((prev) => prev.filter((j) => j.id !== jobId)); try { await api.deleteCronJob(jobId); message.success("Deleted successfully"); return true; } catch (error) { console.error("Failed to delete cron job", error); if (original) { setJobs((prev) => [...prev, original]); } message.error("Failed to delete"); return false; } }; const toggleEnabled = async (job: CronJob) => { const updated = { ...job, enabled: !job.enabled }; setJobs((prev) => prev.map((j) => (j.id === job.id ? updated : j))); try { const returned = await api.replaceCronJob(job.id, updated); setJobs((prev) => prev.map((j) => (j.id === job.id ? (returned as CronJob) : j)), ); message.success(`${updated.enabled ? "Enabled" : "Disabled"}`); return true; } catch (error) { console.error("Failed to toggle cron job", error); setJobs((prev) => prev.map((j) => (j.id === job.id ? job : j))); message.error("Operation failed"); return false; } }; const executeNow = async (jobId: string) => { try { await api.triggerCronJob(jobId); message.success("Task triggered successfully"); return true; } catch (error) { console.error("Failed to execute cron job", error); message.error("Failed to execute"); return false; } }; return { jobs, loading, createJob, updateJob, deleteJob, toggleEnabled, executeNow, }; } ================================================ FILE: console/src/pages/Control/Heartbeat/index.module.less ================================================ .heartbeatPage { padding: 24px; } .title { margin-bottom: 4px; font-size: 24px; font-weight: 600; } .description { margin: 0 0 24px; color: #999; font-size: 14px; } .card { max-width: 560px; } .everyField { :global(.ant-form-item-control-input-content) { display: block; } } .everyRow { display: flex; gap: 12px; align-items: center; } .everyNumber { width: 120px; } .everyUnit { min-width: 100px; } .formActions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; } .activeHoursRow { display: flex; gap: 16px; align-items: flex-start; } .activeHoursRow :global(.ant-form-item) { flex: 1; margin-bottom: 0; } /* ─── Dark mode ─────────────────────────────────────────────────────────────── */ :global(.dark-mode) { .title { color: rgba(255, 255, 255, 0.85); } .description { color: rgba(255, 255, 255, 0.35); } } ================================================ FILE: console/src/pages/Control/Heartbeat/index.tsx ================================================ import { useEffect, useState } from "react"; import { Button, Card, Form, InputNumber, message, Select, Switch, } from "@agentscope-ai/design"; import { TimePicker } from "antd"; import dayjs from "dayjs"; import customParseFormat from "dayjs/plugin/customParseFormat"; import { useTranslation } from "react-i18next"; import api from "../../../api"; import { useAgentStore } from "../../../stores/agentStore"; import type { HeartbeatConfig } from "../../../api/types/heartbeat"; import { parseEvery, serializeEvery, type EveryUnit } from "./parseEvery"; import styles from "./index.module.less"; dayjs.extend(customParseFormat); const TIME_FORMAT = "HH:mm"; /** TimePicker that uses "HH:mm" string as value for Form. */ function TimePickerHHmm({ value, onChange, }: { value?: string | null; onChange?: (s: string) => void; }) { const strVal = typeof value === "string" ? value : Array.isArray(value) ? value[0] : null; return ( { const s = typeof str === "string" ? str : str?.[0]; if (s) onChange?.(s); }} minuteStep={15} needConfirm={false} style={{ width: "100%" }} /> ); } /** Form values: API shape plus flattened fields for interval and time. */ type HeartbeatFormValues = Omit & { every?: string; everyNumber?: number; everyUnit?: EveryUnit; useActiveHours?: boolean; activeHoursStart?: string; activeHoursEnd?: string; }; const TARGET_OPTIONS = [ { value: "main", labelKey: "heartbeat.targetMain" }, { value: "last", labelKey: "heartbeat.targetLast" }, ]; const EVERY_UNIT_OPTIONS: { value: EveryUnit; labelKey: string }[] = [ { value: "m", labelKey: "heartbeat.unitMinutes" }, { value: "h", labelKey: "heartbeat.unitHours" }, ]; function HeartbeatPage() { const { t } = useTranslation(); const { selectedAgent } = useAgentStore(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [form] = Form.useForm(); const fetchConfig = async () => { setLoading(true); try { const data = await api.getHeartbeatConfig(); const everyParts = parseEvery(data.every ?? "6h"); form.setFieldsValue({ enabled: data.enabled ?? false, everyNumber: everyParts.number, everyUnit: everyParts.unit, target: data.target ?? "main", useActiveHours: !!data.activeHours, activeHoursStart: data.activeHours?.start ?? "08:00", activeHoursEnd: data.activeHours?.end ?? "22:00", }); } catch (e) { console.error("Failed to load heartbeat config:", e); message.error(t("heartbeat.loadFailed")); } finally { setLoading(false); } }; useEffect(() => { fetchConfig(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedAgent]); const onFinish = async (values: HeartbeatFormValues) => { const every = values.everyNumber != null && values.everyUnit ? serializeEvery({ number: values.everyNumber, unit: values.everyUnit, }) : "6h"; const body: HeartbeatConfig = { enabled: values.enabled ?? false, every, target: values.target ?? "main", activeHours: values.useActiveHours && values.activeHoursStart && values.activeHoursEnd ? { start: values.activeHoursStart, end: values.activeHoursEnd, } : undefined, }; setSaving(true); try { await api.updateHeartbeatConfig(body); message.success(t("heartbeat.saveSuccess")); } catch (e) { console.error("Failed to save heartbeat config:", e); message.error(t("heartbeat.saveFailed")); } finally { setSaving(false); } }; if (loading) { return (

{t("heartbeat.title")}

{t("heartbeat.description")}

{t("common.loading")}
); } return (

{t("heartbeat.title")}

{t("heartbeat.description")}

({ value: opt.value, label: t(opt.labelKey), }))} /> prev.useActiveHours !== cur.useActiveHours } > {({ getFieldValue }) => getFieldValue("useActiveHours") ? (
) : null }
); } export default HeartbeatPage; ================================================ FILE: console/src/pages/Control/Heartbeat/parseEvery.ts ================================================ /** * Parse backend "every" string (e.g. "6h", "30m", "2h30m") to number + unit * for form display. Serialize back to string for API. */ const EVERY_RE = /^(?:(?\d+)h)?(?:(?\d+)m)?(?:(?\d+)s)?$/i; export type EveryUnit = "m" | "h"; export interface EveryParts { number: number; unit: EveryUnit; } export function parseEvery(every: string): EveryParts { const s = (every || "").trim(); if (!s) { return { number: 6, unit: "h" }; } const m = s.match(EVERY_RE); if (!m || !m.groups) { return { number: 6, unit: "h" }; } const hours = parseInt(m.groups.hours ?? "0", 10); const minutes = parseInt(m.groups.minutes ?? "0", 10); const seconds = parseInt(m.groups.seconds ?? "0", 10); const totalMinutes = hours * 60 + minutes + Math.round(seconds / 60); if (totalMinutes <= 0) { return { number: 6, unit: "h" }; } if (totalMinutes >= 60 && totalMinutes % 60 === 0) { return { number: totalMinutes / 60, unit: "h" }; } return { number: totalMinutes, unit: "m" }; } export function serializeEvery(parts: EveryParts): string { if (parts.unit === "h") { return `${parts.number}h`; } return `${parts.number}m`; } ================================================ FILE: console/src/pages/Control/Sessions/components/FilterBar.tsx ================================================ import { Input, Select } from "@agentscope-ai/design"; import { useTranslation } from "react-i18next"; import styles from "../index.module.less"; interface FilterBarProps { filterUserId: string; filterChannel: string; uniqueChannels: string[]; onUserIdChange: (value: string) => void; onChannelChange: (value: string) => void; } export function FilterBar({ filterUserId, filterChannel, uniqueChannels, onUserIdChange, onChannelChange, }: FilterBarProps) { const { t } = useTranslation(); return (
onUserIdChange(e.target.value)} allowClear className="sessions-filter-input" style={{ width: 200 }} />
); } ================================================ FILE: console/src/pages/Control/Sessions/components/SessionDrawer.tsx ================================================ import { Drawer, Form, Input, Button } from "@agentscope-ai/design"; import { useTranslation } from "react-i18next"; import type { FormInstance } from "antd"; import type { Session } from "./constants"; import styles from "../index.module.less"; interface SessionDrawerProps { open: boolean; editingSession: Session | null; form: FormInstance; saving: boolean; onClose: () => void; onSubmit: (values: Session) => void; } export function SessionDrawer({ open, editingSession, form, saving, onClose, onSubmit, }: SessionDrawerProps) { const { t } = useTranslation(); const drawerFooter = (
); return (
{editingSession && ( <> )}
); } ================================================ FILE: console/src/pages/Control/Sessions/components/columns.tsx ================================================ import { Button, Tag } from "@agentscope-ai/design"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import type { ColumnsType } from "antd/es/table"; import { CHANNEL_COLORS, formatTime, type Session } from "./constants"; interface ColumnHandlers { onEdit: (session: Session) => void; onDelete: (sessionId: string) => void; t: TFunction; } /** Normalize ISO string to UTC for consistent sorting across mixed timezone formats. */ const toUTCTime = (ts: string | null | undefined): number => { if (!ts) return 0; const normalized = /[Z+\-]\d{2}:?\d{2}$/.test(ts) || ts.endsWith("Z") ? ts : ts + "Z"; return new Date(normalized).getTime(); }; export const createColumns = ( handlers: ColumnHandlers, ): ColumnsType => { const { t } = useTranslation(); return [ { title: "ID", dataIndex: "id", key: "id", width: 250, }, { title: "Name", dataIndex: "name", key: "name", width: 200, }, { title: "SessionID", dataIndex: "session_id", key: "session_id", width: 180, }, { title: "UserID", dataIndex: "user_id", key: "user_id", width: 150, }, { title: "Channel", dataIndex: "channel", key: "channel", width: 120, render: (channel: string) => ( {channel} ), }, { title: "CreatedAt", dataIndex: "created_at", key: "created_at", width: 180, render: (timestamp: string | number | null) => formatTime(timestamp), sorter: (a: Session, b: Session) => toUTCTime(a.created_at) - toUTCTime(b.created_at), }, { title: "UpdatedAt", dataIndex: "updated_at", key: "updated_at", width: 180, render: (timestamp: string | number | null) => formatTime(timestamp), sorter: (a: Session, b: Session) => toUTCTime(a.updated_at) - toUTCTime(b.updated_at), defaultSortOrder: "descend", }, { title: "Action", key: "action", width: 180, fixed: "right", render: (_: unknown, record: Session) => (
), }, ]; }; ================================================ FILE: console/src/pages/Control/Sessions/components/constants.ts ================================================ import type { ChatSpec } from "../../../../api/types"; export interface Session extends ChatSpec { name?: string; } export const CHANNEL_COLORS: Record = { imessage: "blue", discord: "purple", dingtalk: "cyan", feishu: "magenta", qq: "orange", telegram: "geekblue", mqtt: "gold", console: "green", } as const; /** * Normalize ISO timestamp to ensure UTC timezone is always recognized. * Timestamps without timezone suffix (e.g. from datetime.utcnow()) are * treated as local time by browsers, causing incorrect display. Appending * 'Z' forces UTC interpretation, consistent with timezone-aware timestamps. */ const normalizeTimestamp = (timestamp: string): string => { if (/[Z+\-]\d{2}:?\d{2}$/.test(timestamp) || timestamp.endsWith("Z")) { return timestamp; } return timestamp + "Z"; }; export const formatTime = (timestamp: string | number | null): string => { if (timestamp === null || timestamp === undefined) return "N/A"; const normalized = typeof timestamp === "string" ? normalizeTimestamp(timestamp) : timestamp; const date = new Date(normalized); return date.toLocaleString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }); }; ================================================ FILE: console/src/pages/Control/Sessions/components/index.ts ================================================ export { createColumns } from "./columns"; export { FilterBar } from "./FilterBar"; export { SessionDrawer } from "./SessionDrawer"; export { CHANNEL_COLORS, formatTime, type Session } from "./constants"; ================================================ FILE: console/src/pages/Control/Sessions/index.module.less ================================================ .sessionsPage { padding: 24px; height: calc(100vh - 128px); } .selectedRow > td { background-color: #e6f7ff !important; } .selectedRow:hover > td { background-color: #bae7ff !important; } .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } .headerInfo { display: flex; flex-direction: column; } .title { margin-bottom: 4px; font-size: 24px; font-weight: 600; } .description { margin: 0; color: #999; font-size: 14px; } .filterBar { margin-bottom: 16px; display: flex; gap: 12px; align-items: center; } /* Input and Select styling */ :global(.sessions-filter-input) { .ant-input { border-radius: 8px !important; border: 1px solid #d9d9d9 !important; transition: all 0.2s ease-in-out !important; &:hover { border-color: #615ced !important; } &:focus { border-color: #615ced !important; box-shadow: 0 0 0 2px rgba(97, 92, 237, 0.2) !important; } } } :global(.sessions-filter-select) { .ant-select-selector { border-radius: 8px !important; border: 1px solid #d9d9d9 !important; transition: all 0.2s ease-in-out !important; &:hover { border-color: #615ced !important; } &:focus, &:focus-within { border-color: #615ced !important; box-shadow: 0 0 0 2px rgba(97, 92, 237, 0.2) !important; } } } .tableCard { :global(.ant-card-body) { padding: 0; } } .formActions { display: flex; justify-content: flex-end; gap: 8px; } /* ─── Dark mode ─────────────────────────────────────────────────────────────── */ :global(.dark-mode) { .selectedRow > td { background-color: rgba(97, 92, 237, 0.12) !important; } .selectedRow:hover > td { background-color: rgba(97, 92, 237, 0.2) !important; } .title { color: rgba(255, 255, 255, 0.85); } .description { color: rgba(255, 255, 255, 0.35); } /* Filter input and select */ :global(.sessions-filter-input) { :global(.copaw-input), :global(.ant-input) { background: #2a2a2a !important; border-color: rgba(255, 255, 255, 0.15) !important; color: rgba(255, 255, 255, 0.85) !important; &::placeholder { color: rgba(255, 255, 255, 0.25) !important; } } } :global(.sessions-filter-select) { :global(.copaw-select-selector), :global(.ant-select-selector) { background: #2a2a2a !important; border-color: rgba(255, 255, 255, 0.15) !important; color: rgba(255, 255, 255, 0.85) !important; } :global(.copaw-select-selection-placeholder), :global(.ant-select-selection-placeholder) { color: rgba(255, 255, 255, 0.25) !important; } :global(.copaw-select-arrow), :global(.ant-select-arrow) { color: rgba(255, 255, 255, 0.3) !important; } } } ================================================ FILE: console/src/pages/Control/Sessions/index.tsx ================================================ import { useEffect, useState } from "react"; import { Card, Form, Modal, Table, message, Button, } from "@agentscope-ai/design"; import { useTranslation } from "react-i18next"; import { createColumns, FilterBar, SessionDrawer, type Session, } from "./components"; import { useSessions } from "./useSessions"; import api from "../../../api"; import styles from "./index.module.less"; function SessionsPage() { const { t } = useTranslation(); const { sessions, loading, updateSession, deleteSession, batchDeleteSessions, } = useSessions(); const [filteredSessions, setFilteredSessions] = useState([]); const [drawerOpen, setDrawerOpen] = useState(false); const [editingSession, setEditingSession] = useState(null); const [saving, setSaving] = useState(false); const [form] = Form.useForm(); const [selectedRowKeys, setSelectedRowKeys] = useState([]); // Filter states const [filterUserId, setFilterUserId] = useState(""); const [filterChannel, setFilterChannel] = useState(""); const [availableChannels, setAvailableChannels] = useState([]); useEffect(() => { const fetchChannelTypes = async () => { try { const types = await api.listChannelTypes(); setAvailableChannels(types); } catch (error) { console.error("❌ Failed to load channel types:", error); } }; fetchChannelTypes(); }, []); // Filter effect useEffect(() => { let filtered: Session[] = sessions; if (filterUserId) { filtered = filtered.filter( (session: Session) => session.user_id?.toLowerCase().includes(filterUserId.toLowerCase()), ); } if (filterChannel) { filtered = filtered.filter( (session: Session) => session.channel === filterChannel, ); } setFilteredSessions(filtered); }, [sessions, filterUserId, filterChannel]); const handleEdit = (session: Session) => { setEditingSession(session); form.setFieldsValue(session as any); setDrawerOpen(true); }; const handleDelete = (sessionId: string) => { Modal.confirm({ title: t("sessions.confirmDelete"), content: t("sessions.deleteConfirm"), okText: t("cronJobs.deleteText"), okType: "primary", cancelText: t("cronJobs.cancelText"), onOk: async () => { await deleteSession(sessionId); }, }); }; const handleBatchDelete = () => { if (selectedRowKeys.length === 0) { message.warning(t("sessions.batchDeleteConfirm", { count: 0 })); return; } Modal.confirm({ title: t("sessions.confirmDelete"), content: t("sessions.batchDeleteConfirm", { count: selectedRowKeys.length, }), okText: t("cronJobs.deleteText"), okType: "danger", cancelText: t("cronJobs.cancelText"), onOk: async () => { const success = await batchDeleteSessions(selectedRowKeys as string[]); if (success) { setSelectedRowKeys([]); } }, }); }; const handleDrawerClose = () => { setDrawerOpen(false); setEditingSession(null); }; const handleSubmit = async (values: Session) => { if (editingSession) { setSaving(true); try { const updated = { ...editingSession, name: values.name, }; const success = await updateSession(editingSession.id, updated); if (success) { setDrawerOpen(false); } } finally { setSaving(false); } } }; const columns = createColumns({ onEdit: handleEdit, onDelete: handleDelete, t, }); const rowSelection = { fixed: true, columnWidth: 50, selectedRowKeys, onChange: (newSelectedRowKeys: React.Key[]) => { setSelectedRowKeys(newSelectedRowKeys); }, }; return (

{t("sessions.title")}

{t("sessions.description")}

{selectedRowKeys.length > 0 && ( )}
selectedRowKeys.includes(record.id) ? styles.selectedRow : "" } scroll={{ x: 1500 }} pagination={{ pageSize: 10, showTotal: (total) => t("sessions.totalItems", { count: total }), }} /> ); } export default SessionsPage; ================================================ FILE: console/src/pages/Control/Sessions/useSessions.ts ================================================ import { useState, useEffect } from "react"; import { message } from "@agentscope-ai/design"; import api from "../../../api"; import type { Session } from "./components/constants"; import { useAgentStore } from "../../../stores/agentStore"; export function useSessions() { const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(true); const { selectedAgent } = useAgentStore(); const fetchSessions = async () => { setLoading(true); try { const data = await api.listSessions(); if (data) { setSessions(data as Session[]); } } catch (error) { console.error("❌ Failed to load sessions:", error); } finally { setLoading(false); } }; useEffect(() => { let mounted = true; const loadSessions = async () => { await fetchSessions(); }; if (mounted) { loadSessions(); } return () => { mounted = false; }; }, [selectedAgent]); const updateSession = async (sessionId: string, values: Session) => { try { const result = await api.updateSession(sessionId, values); setSessions(sessions.map((s) => (s.id === sessionId ? result : s))); message.success("Saved successfully"); return true; } catch (error) { console.error("❌ Failed to save session:", error); message.error("Save failed"); return false; } }; const deleteSession = async (sessionId: string) => { try { await api.deleteSession(sessionId); setSessions(sessions.filter((s) => s.id !== sessionId)); message.success("Deleted successfully"); return true; } catch (error) { console.error("❌ Failed to delete session:", error); message.error("Failed to delete"); return false; } }; const batchDeleteSessions = async (sessionIds: string[]) => { try { await api.batchDeleteSessions(sessionIds); setSessions(sessions.filter((s) => !sessionIds.includes(s.id))); message.success(`Successfully deleted ${sessionIds.length} session(s)`); return true; } catch (error) { console.error("❌ Failed to batch delete sessions:", error); message.error("Failed to batch delete sessions"); return false; } }; return { sessions, loading, updateSession, deleteSession, batchDeleteSessions, }; } ================================================ FILE: console/src/pages/Login/index.tsx ================================================ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useSearchParams } from "react-router-dom"; import { Button, Card, Form, Input, message } from "antd"; import { LockOutlined, UserOutlined } from "@ant-design/icons"; import { authApi } from "../../api/modules/auth"; import { setAuthToken } from "../../api/config"; export default function LoginPage() { const { t } = useTranslation(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [loading, setLoading] = useState(false); const [isRegister, setIsRegister] = useState(false); const [hasUsers, setHasUsers] = useState(true); useEffect(() => { authApi .getStatus() .then((res) => { if (!res.enabled) { navigate("/chat", { replace: true }); return; } setHasUsers(res.has_users); if (!res.has_users) { setIsRegister(true); } }) .catch(() => {}); }, [navigate]); const onFinish = async (values: { username: string; password: string }) => { setLoading(true); try { const raw = searchParams.get("redirect") || "/chat"; const redirect = raw.startsWith("/") && !raw.startsWith("//") ? raw : "/chat"; if (isRegister) { const res = await authApi.register(values.username, values.password); if (res.token) { setAuthToken(res.token); message.success(t("login.registerSuccess")); navigate(redirect, { replace: true }); } } else { const res = await authApi.login(values.username, values.password); if (res.token) { setAuthToken(res.token); navigate(redirect, { replace: true }); } else { message.info(t("login.authNotEnabled")); navigate(redirect, { replace: true }); } } } catch (err) { message.error( isRegister ? err instanceof Error ? err.message : t("login.registerFailed") : t("login.failed"), ); } finally { setLoading(false); } }; return (
CoPaw

{isRegister ? t("login.registerTitle") : t("login.title")}

{!hasUsers && (

{t("login.firstUserHint")}

)}
} placeholder={t("login.usernamePlaceholder")} autoFocus /> } placeholder={t("login.passwordPlaceholder")} />
); } ================================================ FILE: console/src/pages/Settings/Agents/components/AgentModal.tsx ================================================ import { Modal, Form, Input } from "antd"; import { useTranslation } from "react-i18next"; import type { AgentSummary } from "@/api/types/agents"; interface AgentModalProps { open: boolean; editingAgent: AgentSummary | null; form: ReturnType[0]; onSave: () => Promise; onCancel: () => void; } export function AgentModal({ open, editingAgent, form, onSave, onCancel, }: AgentModalProps) { const { t } = useTranslation(); return (
{editingAgent && ( )}
); } ================================================ FILE: console/src/pages/Settings/Agents/components/AgentTable.tsx ================================================ import { Table, Button, Space, Popconfirm } from "antd"; import type { ColumnsType } from "antd/es/table"; import { useTranslation } from "react-i18next"; import { EditOutlined, DeleteOutlined, RobotOutlined } from "@ant-design/icons"; import type { AgentSummary } from "../../../../api/types/agents"; import { useTheme } from "../../../../contexts/ThemeContext"; import styles from "../index.module.less"; interface AgentTableProps { agents: AgentSummary[]; loading: boolean; onEdit: (agent: AgentSummary) => void; onDelete: (agentId: string) => void; } export function AgentTable({ agents, loading, onEdit, onDelete, }: AgentTableProps) { const { t } = useTranslation(); const { isDark } = useTheme(); // Inline style for disabled buttons — CSS cannot reliably override AntD's disabled styles const disabledStyle: React.CSSProperties = isDark ? { color: "rgba(255,255,255,0.35)", opacity: 1 } : {}; const columns: ColumnsType = [ { title: t("agent.name"), dataIndex: "name", key: "name", render: (text: string) => ( {text} ), }, { title: t("agent.id"), dataIndex: "id", key: "id", }, { title: t("agent.description"), dataIndex: "description", key: "description", ellipsis: true, }, { title: t("agent.workspace"), dataIndex: "workspace_dir", key: "workspace_dir", ellipsis: true, }, { title: t("common.actions"), key: "actions", width: 200, render: (_: any, record: AgentSummary) => ( onDelete(record.id)} disabled={record.id === "default"} okText={t("common.confirm")} cancelText={t("common.cancel")} > ), }, ]; return (
); } ================================================ FILE: console/src/pages/Settings/Agents/components/PageHeader.tsx ================================================ import styles from "../index.module.less"; interface PageHeaderProps { title: string; description?: string; className?: string; action?: React.ReactNode; } export function PageHeader({ title, description, className, action, }: PageHeaderProps) { return (

{title}

{description &&

{description}

}
{action &&
{action}
}
); } ================================================ FILE: console/src/pages/Settings/Agents/components/index.ts ================================================ export { PageHeader } from "./PageHeader"; export { AgentTable } from "./AgentTable"; export { AgentModal } from "./AgentModal"; ================================================ FILE: console/src/pages/Settings/Agents/index.module.less ================================================ .agentsPage { padding: 24px; } /* ---- Section (same as Models / Environments) ---- */ .section { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: center; } .section:last-child { margin-bottom: 0; } .sectionTitle { margin: 0; font-size: 24px; font-weight: 600; } .sectionDesc { color: #999; font-size: 14px; } /* ---- Table Card ---- */ .tableCard { margin-bottom: 24px; &:last-child { margin-bottom: 0; } } /* ─── Dark mode ─────────────────────────────────────────────────────────────── */ :global(.dark-mode) { .sectionTitle { color: rgba(255, 255, 255, 0.85); } .sectionDesc { color: rgba(255, 255, 255, 0.35); } } /* Link buttons in table — must be flat global selectors to beat AntD specificity */ :global(.dark-mode .ant-btn-link) { color: #8b87f0 !important; &:global(:hover) { color: #a5a2f5 !important; } } :global(.dark-mode .ant-btn-link.ant-btn-dangerous) { color: #ff7875 !important; &:global(:hover) { color: #ff9c9a !important; } } :global(.dark-mode .ant-btn-link:disabled), :global(.dark-mode .ant-btn-link.ant-btn-disabled), :global(.dark-mode .ant-btn-link[disabled]) { color: rgba(255, 255, 255, 0.35) !important; opacity: 1 !important; } ================================================ FILE: console/src/pages/Settings/Agents/index.tsx ================================================ import { useState } from "react"; import { Card, Button, Form, message } from "antd"; import { PlusOutlined } from "@ant-design/icons"; import { useTranslation } from "react-i18next"; import { agentsApi } from "../../../api/modules/agents"; import type { AgentSummary } from "../../../api/types/agents"; import { useAgents } from "./useAgents"; import { PageHeader, AgentTable, AgentModal } from "./components"; import styles from "./index.module.less"; export default function AgentsPage() { const { t } = useTranslation(); const { agents, loading, deleteAgent } = useAgents(); const [modalVisible, setModalVisible] = useState(false); const [editingAgent, setEditingAgent] = useState(null); const [form] = Form.useForm(); const handleCreate = () => { setEditingAgent(null); form.resetFields(); form.setFieldsValue({ workspace_dir: "", }); setModalVisible(true); }; const handleEdit = async (agent: AgentSummary) => { try { const config = await agentsApi.getAgent(agent.id); setEditingAgent(agent); form.setFieldsValue(config); setModalVisible(true); } catch (error) { console.error("Failed to load agent config:", error); message.error(t("agent.loadConfigFailed")); } }; const handleDelete = async (agentId: string) => { try { await deleteAgent(agentId); } catch { // Error already handled in hook message.error(t("agent.deleteFailed")); } }; const handleSubmit = async () => { try { const values = await form.validateFields(); if (editingAgent) { await agentsApi.updateAgent(editingAgent.id, values); message.success(t("agent.updateSuccess")); } else { const result = await agentsApi.createAgent(values); message.success(`${t("agent.createSuccess")} (ID: ${result.id})`); } setModalVisible(false); } catch (error: any) { console.error("Failed to save agent:", error); message.error(error.message || t("agent.saveFailed")); } }; return (
} onClick={handleCreate}> {t("agent.create")} } /> setModalVisible(false)} />
); } ================================================ FILE: console/src/pages/Settings/Agents/useAgents.ts ================================================ import { useState, useEffect } from "react"; import { message } from "antd"; import { useTranslation } from "react-i18next"; import { agentsApi } from "@/api/modules/agents"; import type { AgentSummary } from "@/api/types/agents"; import { useAgentStore } from "@/stores/agentStore"; interface UseAgentsReturn { agents: AgentSummary[]; loading: boolean; error: Error | null; loadAgents: () => Promise; deleteAgent: (agentId: string) => Promise; } export function useAgents(): UseAgentsReturn { const { t } = useTranslation(); const [agents, setAgents] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const { setAgents: updateStoreAgents } = useAgentStore(); const loadAgents = async () => { setLoading(true); setError(null); try { const data = await agentsApi.listAgents(); setAgents(data.agents); updateStoreAgents(data.agents); } catch (err) { console.error("Failed to load agents:", err); const errorMsg = err instanceof Error ? err : new Error(t("agent.loadFailed")); setError(errorMsg); message.error(t("agent.loadFailed")); } finally { setLoading(false); } }; const deleteAgent = async (agentId: string) => { try { await agentsApi.deleteAgent(agentId); message.success(t("agent.deleteSuccess")); await loadAgents(); } catch (err: any) { message.error(err.message || t("agent.deleteFailed")); throw err; } }; useEffect(() => { loadAgents(); }, []); return { agents, loading, error, loadAgents, deleteAgent, }; } ================================================ FILE: console/src/pages/Settings/Environments/components/AddButton.tsx ================================================ import { SparkPlusLine } from "@agentscope-ai/icons"; import { useTranslation } from "react-i18next"; import styles from "../index.module.less"; interface AddButtonProps { onClick: () => void; className?: string; } export function AddButton({ onClick, className }: AddButtonProps) { const { t } = useTranslation(); return (
); } ================================================ FILE: console/src/pages/Settings/Environments/components/EmptyState.tsx ================================================ import { useTranslation } from "react-i18next"; import styles from "../index.module.less"; interface EmptyStateProps { className?: string; } export function EmptyState({ className }: EmptyStateProps) { const { t } = useTranslation(); return (
📦 {t("environments.noVariables")}
); } ================================================ FILE: console/src/pages/Settings/Environments/components/EnvRow.tsx ================================================ import { Checkbox, Input } from "@agentscope-ai/design"; import { SparkDeleteLine, SparkPlusLine } from "@agentscope-ai/icons"; import { EyeOutlined, EyeInvisibleOutlined } from "@ant-design/icons"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import styles from "../index.module.less"; export interface Row { key: string; value: string; isNew?: boolean; } interface EnvRowProps { row: Row; idx: number; checked: boolean; error?: string; onToggle: (idx: number) => void; onChange: (idx: number, field: "key" | "value", val: string) => void; onInsert: (idx: number) => void; onRemove: (idx: number) => void; } export function EnvRow({ row, idx, checked, error, onToggle, onChange, onInsert, onRemove, }: EnvRowProps) { const { t } = useTranslation(); const [isPasswordVisible, setIsPasswordVisible] = useState(false); return (
onToggle(idx)} className={styles.rowCheckbox} />
Key onChange(idx, "key", e.target.value)} className={styles.inputField} autoFocus={row.isNew} />
Value onChange(idx, "value", e.target.value)} className={styles.inputField} suffix={ } />
{error &&
{error}
}
); } ================================================ FILE: console/src/pages/Settings/Environments/components/PageHeader.tsx ================================================ import { useTranslation } from "react-i18next"; import styles from "../index.module.less"; interface PageHeaderProps { className?: string; } export function PageHeader({ className }: PageHeaderProps) { const { t } = useTranslation(); return (

{t("environments.title")}

{t("environments.description")}

); } ================================================ FILE: console/src/pages/Settings/Environments/components/Toolbar.tsx ================================================ import { Checkbox, Button } from "@agentscope-ai/design"; import { SparkDeleteLine } from "@agentscope-ai/icons"; import { useTranslation } from "react-i18next"; import styles from "../index.module.less"; interface ToolbarProps { workingRowsLength: number; allSelected: boolean; someSelected: boolean; selectedSize: number; dirty: boolean; saving: boolean; indeterminate: boolean; onToggleSelectAll: () => void; onRemoveSelected: () => void; onReset: () => void; onSave: () => void; className?: string; } export function Toolbar({ workingRowsLength, allSelected, someSelected, selectedSize, dirty, saving, indeterminate, onToggleSelectAll, onRemoveSelected, onReset, onSave, className, }: ToolbarProps) { const { t } = useTranslation(); return (
{workingRowsLength > 0 && ( )} {someSelected ? `${selectedSize} ${t("environments.of")} ${workingRowsLength} ${t( "environments.selected", )}` : `${workingRowsLength} ${ workingRowsLength !== 1 ? t("environments.variables") : t("environments.variable") }`}
{someSelected && ( )} {dirty && ( <> )}
); } ================================================ FILE: console/src/pages/Settings/Environments/components/index.ts ================================================ export * from "./PageHeader"; export * from "./EmptyState"; export * from "./AddButton"; export * from "./Toolbar"; export * from "./EnvRow"; ================================================ FILE: console/src/pages/Settings/Environments/index.module.less ================================================ .environmentsPage { padding: 24px; } /* ---- Section (matches Models page) ---- */ .section { margin-bottom: 24px; } .sectionTitle { margin-bottom: 4px; font-size: 24px; font-weight: 600; } .sectionDesc { margin: 0; color: #999; font-size: 14px; } /* ---- Loading / Error ---- */ .centerState { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 80px 24px; } .stateText { font-size: 14px; color: #999; } .stateTextError { font-size: 14px; color: #ff4d4f; margin-bottom: 4px; } /* ---- Table card ---- */ .tableCard { background: #fff; border: 1px solid #e8e8e8; border-radius: 16px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); overflow: hidden; transition: all 0.2s ease-in-out; &:hover { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); border-color: #d9d9d9; } } /* ---- Toolbar ---- */ .toolbar { display: flex; align-items: center; justify-content: space-between; padding: 16px 24px; background: #fafafa; border-bottom: 1px solid #f0f0f0; transition: background 0.2s ease-in-out; &:hover { background: #f5f5f5; } } .toolbarLeft { display: flex; align-items: center; gap: 10px; } .toolbarCount { font-size: 13px; color: #999; user-select: none; } .toolbarRight { display: flex; align-items: center; gap: 8px; } /* ---- Row list ---- */ .rowList { display: flex; flex-direction: column; } /* ---- Env row ---- */ .envRow { display: flex; align-items: center; gap: 16px; padding: 12px 24px; border-bottom: 1px solid #f5f5f5; transition: all 0.2s ease-in-out; flex-wrap: wrap; &:last-child { border-bottom: none; } &:hover { background: #fafbfc; transform: translateX(2px); } } .envRowSelected { background: #e6f4ff; border-left: 3px solid #1677ff; &:hover { background: #d6eaff; transform: translateX(2px); } } .envRowSelected { background: #e6f4ff; &:hover { background: #d6eaff; } } .rowCheckbox { flex-shrink: 0; } /* Key + Value inputs side-by-side */ .fieldsWrap { display: flex; flex: 1; min-width: 0; gap: 16px; } .inputGroup { display: flex; align-items: center; flex: 1 1 0; min-width: 180px; height: 42px; border: 1px solid #e0e0e0; border-radius: 8px; background: #fff; overflow: hidden; transition: all 0.2s ease-in-out; &:focus-within { border-color: #615ced; box-shadow: 0 0 0 2px rgba(97, 92, 237, 0.2); transform: translateY(-1px); } } .inputGroupError { border-color: #ff4d4f; animation: shake 0.3s ease-in-out; &:focus-within { border-color: #ff4d4f; box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.2); } } @keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-2px); } 75% { transform: translateX(2px); } } .inputLabel { flex-shrink: 0; padding: 0 14px; font-size: 14px; font-weight: 500; color: #666; white-space: nowrap; user-select: none; line-height: 42px; border-right: 1px solid #f0f0f0; background: #fafafa; min-width: 60px; text-align: center; transition: all 0.2s ease-in-out; .inputGroup:focus-within & { color: #615ced; background: #f0f0ff; } } .inputField { flex: 1; min-width: 0; height: 40px !important; border: none !important; box-shadow: none !important; outline: none !important; font-family: "SF Mono", "Menlo", "Consolas", monospace; font-size: 14px; background: transparent; padding-left: 12px; transition: all 0.2s ease-in-out; &::placeholder { color: #bfbfbf; } } .inputField { :global(.ant-input) { height: 40px !important; border: none !important; box-shadow: none !important; font-family: "SF Mono", "Menlo", "Consolas", monospace; font-size: 14px; padding-left: 12px; transition: all 0.2s ease-in-out; &::placeholder { color: #bfbfbf; } } } /* Password toggle button */ .passwordToggle { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border: none; background: transparent; cursor: pointer; color: #999; transition: all 0.2s ease-in-out; border-radius: 4px; &:hover { color: #615ced; background: #f0f0ff; } } /* ---- Row action buttons ---- */ .rowActions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } .rowIconBtn { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border: none; border-radius: 8px; background: transparent; cursor: pointer; font-size: 16px; color: #c0c0c0; transition: all 0.2s ease-in-out; &:hover { color: #615ced; background: #f0f0ff; transform: translateY(-1px); box-shadow: 0 2px 8px rgba(97, 92, 237, 0.15); } } .rowIconBtnDanger { &:hover { color: #ff4d4f; background: #fff1f0; transform: translateY(-1px); box-shadow: 0 2px 8px rgba(255, 77, 79, 0.15); } } /* ---- Row error ---- */ .rowError { flex-basis: 100%; font-size: 12px; color: #ff4d4f; padding-left: 36px; /* align with fields after checkbox */ margin-top: -2px; } /* ---- Add bar ---- */ .addBar { display: flex; padding: 16px 24px; border-top: 1px solid #f0f0f0; background: #fafafa; transition: background 0.2s ease-in-out; &:hover { background: #f5f5f5; } } .addBtn { display: inline-flex; align-items: center; gap: 8px; padding: 8px 20px; border: 1px dashed #d9d9d9; border-radius: 8px; background: transparent; cursor: pointer; font-size: 14px; color: #999; transition: all 0.2s ease-in-out; &:hover { color: #615ced; border-color: #615ced; background: #f0f0ff; transform: translateY(-1px); box-shadow: 0 2px 8px rgba(97, 92, 237, 0.15); } svg { font-size: 16px; } } /* ---- Empty state ---- */ .emptyState { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 48px 24px; color: #bbb; font-size: 14px; } .emptyIcon { font-size: 32px; opacity: 0.6; } /* ─── Dark mode ─────────────────────────────────────────────────────────────── */ :global(.dark-mode) { .sectionDesc { color: rgba(255, 255, 255, 0.35); } .stateText { color: rgba(255, 255, 255, 0.35); } /* Main card */ .tableCard { background: #1f1f1f; border-color: rgba(255, 255, 255, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); &:hover { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); border-color: rgba(255, 255, 255, 0.15); } } /* Toolbar */ .toolbar { background: rgba(255, 255, 255, 0.03); border-bottom-color: rgba(255, 255, 255, 0.08); &:hover { background: rgba(255, 255, 255, 0.05); } } .toolbarCount { color: rgba(255, 255, 255, 0.35); } /* Env row */ .envRow { border-bottom-color: rgba(255, 255, 255, 0.06); &:hover { background: rgba(255, 255, 255, 0.04); } } .envRowSelected { background: rgba(22, 119, 255, 0.12); border-left-color: #4096ff; &:hover { background: rgba(22, 119, 255, 0.18); } } /* Input group (Key / Value 外框) */ .inputGroup { border-color: rgba(255, 255, 255, 0.12); background: #2a2a2a; &:focus-within { border-color: #615ced; box-shadow: 0 0 0 2px rgba(97, 92, 237, 0.2); } } /* Key / Value 标签 */ .inputLabel { color: rgba(255, 255, 255, 0.4); border-right-color: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.04); .inputGroup:focus-within & { color: #8b87f0; background: rgba(97, 92, 237, 0.1); } } /* Input field inside group */ .inputField { color: rgba(255, 255, 255, 0.85); &::placeholder { color: rgba(255, 255, 255, 0.2); } :global(.ant-input), :global(.copaw-input) { color: rgba(255, 255, 255, 0.85) !important; background: transparent !important; &::placeholder { color: rgba(255, 255, 255, 0.2) !important; } } } /* Password toggle */ .passwordToggle { color: rgba(255, 255, 255, 0.3); &:hover { color: #8b87f0; background: rgba(97, 92, 237, 0.12); } } /* Row action icons */ .rowIconBtn { color: rgba(255, 255, 255, 0.2); &:hover { color: #8b87f0; background: rgba(97, 92, 237, 0.12); } } .rowIconBtnDanger { &:hover { color: #ff7875; background: rgba(255, 77, 79, 0.12); } } /* Add bar */ .addBar { background: rgba(255, 255, 255, 0.02); border-top-color: rgba(255, 255, 255, 0.08); &:hover { background: rgba(255, 255, 255, 0.04); } } .addBtn { border-color: rgba(255, 255, 255, 0.15); color: rgba(255, 255, 255, 0.35); &:hover { color: #8b87f0; border-color: #615ced; background: rgba(97, 92, 237, 0.1); } } /* Empty state */ .emptyState { color: rgba(255, 255, 255, 0.2); } } ================================================ FILE: console/src/pages/Settings/Environments/index.tsx ================================================ import { useState, useCallback, useMemo } from "react"; import { Button, Modal, message } from "@agentscope-ai/design"; import { useTranslation } from "react-i18next"; import api from "../../../api"; import { useEnvVars } from "./useEnvVars"; import { PageHeader, EmptyState, AddButton, Toolbar, EnvRow, type Row, } from "./components"; import styles from "./index.module.less"; /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ /** Reindex selected set after a splice at `idx`. */ function shiftIndices(prev: Set, removedIdx: number): Set { const next = new Set(); prev.forEach((i) => { if (i < removedIdx) next.add(i); else if (i > removedIdx) next.add(i - 1); }); return next; } /* ------------------------------------------------------------------ */ /* Main Page */ /* ------------------------------------------------------------------ */ function EnvironmentsPage() { const { t } = useTranslation(); const { envVars, loading, error, fetchAll } = useEnvVars(); const [rows, setRows] = useState(null); const [saving, setSaving] = useState(false); const [keyErrors, setKeyErrors] = useState>({}); const [selected, setSelected] = useState>(new Set()); /* ---- derived state ---- */ const workingRows: Row[] = useMemo( () => rows ?? envVars.map((e) => ({ key: e.key, value: e.value })), [rows, envVars], ); const dirty = rows !== null; const someSelected = selected.size > 0; const allSelected = workingRows.length > 0 && workingRows.every((_, i) => selected.has(i)); /* ---- ensure we have a mutable local copy ---- */ const ensureLocal = useCallback((): Row[] => { if (rows) return [...rows]; return envVars.map((e) => ({ key: e.key, value: e.value })); }, [rows, envVars]); /* ---- selection ---- */ const toggleSelect = useCallback((idx: number) => { setSelected((prev) => { const next = new Set(prev); if (next.has(idx)) next.delete(idx); else next.add(idx); return next; }); }, []); const toggleSelectAll = useCallback(() => { if (allSelected) { setSelected(new Set()); } else { setSelected(new Set(workingRows.map((_, i) => i))); } }, [allSelected, workingRows]); /* ---- row mutations ---- */ const updateRow = useCallback( (idx: number, field: "key" | "value", val: string) => { const next = ensureLocal(); next[idx] = { ...next[idx], [field]: val }; setRows(next); if (field === "key") { setKeyErrors((prev) => { const copy = { ...prev }; delete copy[idx]; return copy; }); } }, [ensureLocal], ); const addRow = useCallback(() => { const next = ensureLocal(); next.push({ key: "", value: "", isNew: true }); setRows(next); }, [ensureLocal]); const insertRowAfter = useCallback( (idx: number) => { const next = ensureLocal(); next.splice(idx + 1, 0, { key: "", value: "", isNew: true }); setRows(next); setSelected((prev) => { const rebuilt = new Set(); prev.forEach((i) => rebuilt.add(i <= idx ? i : i + 1)); return rebuilt; }); }, [ensureLocal], ); const removeRow = useCallback( (idx: number) => { const row = workingRows[idx]; // New (unsaved) row — just remove from local state, no API call needed if (row.isNew) { const next = ensureLocal(); next.splice(idx, 1); setRows(next.length === 0 && envVars.length === 0 ? null : next); setSelected((prev) => shiftIndices(prev, idx)); return; } // Persisted row — confirm then call DELETE API immediately Modal.confirm({ title: t("environments.deleteVariable"), content: t("environments.deleteConfirm", { name: row.key }), okText: t("common.delete"), okButtonProps: { danger: true }, cancelText: t("common.cancel"), onOk: async () => { try { await api.deleteEnv(row.key); message.success(t("environments.deleteSuccess", { name: row.key })); // Refresh from server so local state is in sync setRows(null); setSelected(new Set()); setKeyErrors({}); fetchAll(); } catch (err) { const errMsg = err instanceof Error ? err.message : t("environments.deleteFailed"); message.error(errMsg); } }, }); }, [workingRows, ensureLocal, envVars.length, fetchAll], ); const removeSelected = useCallback(() => { if (selected.size === 0) return; const indices = Array.from(selected).sort((a, b) => a - b); const names = indices.map((i) => workingRows[i]?.key).filter(Boolean); const hasPersistedRows = indices.some((i) => !workingRows[i]?.isNew); // All selected rows are new — just remove from local state if (!hasPersistedRows) { const next = ensureLocal().filter((_, i) => !selected.has(i)); setRows(next.length === 0 && envVars.length === 0 ? null : next); setSelected(new Set()); return; } const label = names.length <= 3 ? names.map((n) => `"${n}"`).join(", ") : `${names.length} variables`; Modal.confirm({ title: t("environments.deleteSelected"), content: t("environments.deleteSelectedConfirm", { label }), okText: t("common.delete"), okButtonProps: { danger: true }, cancelText: t("common.cancel"), onOk: async () => { try { const persistedKeysToDelete = indices .map((i) => workingRows[i]) .filter((row) => row && !row.isNew) .map((row) => row.key.trim()) .filter(Boolean); if (persistedKeysToDelete.length > 0) { await Promise.all( persistedKeysToDelete.map((key) => api.deleteEnv(key)), ); } message.success(t("environments.deleteSuccess", { name: label })); setRows(null); setSelected(new Set()); setKeyErrors({}); fetchAll(); } catch (err) { const errMsg = err instanceof Error ? err.message : t("environments.deleteFailed"); message.error(errMsg); } }, }); }, [selected, workingRows, ensureLocal, envVars.length, fetchAll]); /* ---- validate & save ---- */ const validate = useCallback((): boolean => { const errors: Record = {}; const seen = new Set(); for (let i = 0; i < workingRows.length; i++) { const k = workingRows[i].key.trim(); if (!k) { errors[i] = t("environments.keyRequired"); } else if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) { errors[i] = t("environments.invalidKeyFormat"); } else if (seen.has(k)) { errors[i] = t("environments.duplicateKey"); } seen.add(k); } setKeyErrors(errors); return Object.keys(errors).length === 0; }, [workingRows]); const handleSave = useCallback(async () => { if (!validate()) return; const dict: Record = {}; for (const r of workingRows) { dict[r.key.trim()] = r.value; } setSaving(true); try { await api.saveEnvs(dict); message.success(t("environments.saveSuccess")); setRows(null); setKeyErrors({}); setSelected(new Set()); fetchAll(); } catch (err) { const errMsg = err instanceof Error ? err.message : t("environments.saveFailed"); message.error(errMsg); } finally { setSaving(false); } }, [validate, workingRows, fetchAll]); const handleReset = useCallback(() => { setRows(null); setKeyErrors({}); setSelected(new Set()); }, []); /* ---- render ---- */ return (
{/* ---- Page header ---- */} {/* ---- Content ---- */} {loading ? (
{t("environments.loading")}
) : error ? (
{error}
) : (
{/* ---- Toolbar ---- */} {/* ---- Rows ---- */}
{workingRows.map((row, idx) => ( ))} {workingRows.length === 0 && }
{/* ---- Add button ---- */}
)}
); } export default EnvironmentsPage; ================================================ FILE: console/src/pages/Settings/Environments/useEnvVars.ts ================================================ import { useState, useEffect, useCallback } from "react"; import api from "../../../api"; import type { EnvVar } from "../../../api/types"; export function useEnvVars() { const [envVars, setEnvVars] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const fetchAll = useCallback(async () => { setLoading(true); setError(null); try { const data = await api.listEnvs(); if (data) setEnvVars(data); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to load environment variables"; console.error("Failed to load env vars:", err); setError(msg); } finally { setLoading(false); } }, []); useEffect(() => { fetchAll(); }, [fetchAll]); return { envVars, loading, error, fetchAll }; } ================================================ FILE: console/src/pages/Settings/Models/components/ModelManageModal.tsx ================================================ import { useState, useEffect, useCallback, useRef } from "react"; import { Button, Form, Input, Modal, Select, Tag, message, } from "@agentscope-ai/design"; import { CloseOutlined, DeleteOutlined, DownloadOutlined, LoadingOutlined, PlusOutlined, } from "@ant-design/icons"; import type { ProviderInfo, LocalModelResponse, DownloadTaskResponse, OllamaModelResponse, OllamaDownloadTaskResponse, } from "../../../../api/types"; import api from "../../../../api"; import { useTranslation } from "react-i18next"; import styles from "../index.module.less"; const POLL_INTERVAL_MS = 3000; interface ModelManageModalProps { provider: ProviderInfo; open: boolean; onClose: () => void; onSaved: () => void; } function formatFileSize(bytes: number): string { if (bytes === 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; } export function ModelManageModal({ provider, open, onClose, onSaved, }: ModelManageModalProps) { const { t } = useTranslation(); const [adding, setAdding] = useState(false); const [saving, setSaving] = useState(false); const [form] = Form.useForm(); // --- Local provider state --- const [localModels, setLocalModels] = useState([]); const [loadingLocal, setLoadingLocal] = useState(false); const [activeTasks, setActiveTasks] = useState([]); const pollRef = useRef | null>(null); // Track task IDs we've already shown completion/failure messages for const notifiedRef = useRef>(new Set()); // --- Ollama provider state --- const [ollamaModels, setOllamaModels] = useState([]); const [loadingOllama, setLoadingOllama] = useState(false); const [ollamaTasks, setOllamaTasks] = useState( [], ); const ollamaPollRef = useRef | null>(null); const ollamaNotifiedRef = useRef>(new Set()); const stopPolling = useCallback(() => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } }, []); const stopOllamaPolling = useCallback(() => { if (ollamaPollRef.current) { clearInterval(ollamaPollRef.current); ollamaPollRef.current = null; } }, []); const fetchLocalModels = useCallback(async () => { setLoadingLocal(true); try { const data = await api.listLocalModels(provider.id); setLocalModels(Array.isArray(data) ? data : []); } catch { setLocalModels([]); } finally { setLoadingLocal(false); } }, [provider.id]); const pollDownloads = useCallback(async () => { try { const tasks = await api.getDownloadStatus(provider.id); const active = tasks.filter( (t) => t.status === "pending" || t.status === "downloading", ); const terminal = tasks.filter( (t) => t.status === "completed" || t.status === "failed" || t.status === "cancelled", ); // Notify for newly completed/failed/cancelled tasks let needsRefresh = false; for (const task of terminal) { if (!notifiedRef.current.has(task.task_id)) { notifiedRef.current.add(task.task_id); if (task.status === "completed") { message.success(t("models.localDownloadSuccess")); needsRefresh = true; } else if (task.status === "cancelled") { message.info(t("models.localDownloadCancelled")); } else { message.error(task.error || t("models.localDownloadFailed")); } } } if (needsRefresh) { onSaved(); fetchLocalModels(); } setActiveTasks(active); // Stop polling when no active tasks remain if (active.length === 0) { stopPolling(); } } catch { /* ignore polling errors */ } }, [provider.id, t, onSaved, fetchLocalModels, stopPolling]); const startPolling = useCallback(() => { if (pollRef.current) return; // already polling pollRef.current = setInterval(pollDownloads, POLL_INTERVAL_MS); }, [pollDownloads]); // --- Ollama-specific fetch & poll functions --- const fetchOllamaModels = useCallback(async () => { setLoadingOllama(true); try { const data = await api.listOllamaModels(); setOllamaModels(Array.isArray(data) ? data : []); } catch { setOllamaModels([]); } finally { setLoadingOllama(false); } }, []); const pollOllamaDownloads = useCallback(async () => { try { const tasksStatus = await api.getOllamaDownloadStatus(); const tasks = Array.isArray(tasksStatus) ? tasksStatus : []; const active = tasks.filter( (t) => t.status === "pending" || t.status === "downloading", ); const terminal = tasks.filter( (t) => t.status === "completed" || t.status === "failed" || t.status === "cancelled", ); let needsRefresh = false; for (const task of terminal) { if (!ollamaNotifiedRef.current.has(task.task_id)) { ollamaNotifiedRef.current.add(task.task_id); if (task.status === "completed") { message.success(t("models.localDownloadSuccess")); needsRefresh = true; } else if (task.status === "cancelled") { message.info(t("models.localDownloadCancelled")); } else { message.error(task.error || t("models.localDownloadFailed")); } } } if (needsRefresh) { onSaved(); fetchOllamaModels(); } setOllamaTasks(active); if (active.length === 0) { stopOllamaPolling(); } } catch { /* ignore polling errors */ } }, [t, onSaved, fetchOllamaModels, stopOllamaPolling]); const startOllamaPolling = useCallback(() => { if (ollamaPollRef.current) return; ollamaPollRef.current = setInterval(pollOllamaDownloads, POLL_INTERVAL_MS); }, [pollOllamaDownloads]); // On open for local providers: fetch models and check for active downloads useEffect(() => { if (!open || !provider.is_local) return; fetchLocalModels(); setAdding(false); form.resetFields(); notifiedRef.current.clear(); // Initial check api .getDownloadStatus(provider.id) .then((tasks) => { const taskList = Array.isArray(tasks) ? tasks : []; const active = taskList.filter( (t) => t.status === "pending" || t.status === "downloading", ); setActiveTasks(active); if (active.length > 0) { startPolling(); } }) .catch(() => {}); return () => stopPolling(); }, [ open, provider.is_local, provider.id, fetchLocalModels, form, startPolling, stopPolling, ]); // On open for Ollama provider: fetch models and check for active downloads useEffect(() => { if (!open || provider.id !== "ollama") return; fetchOllamaModels(); setAdding(false); form.resetFields(); ollamaNotifiedRef.current.clear(); api .getOllamaDownloadStatus() .then((tasks) => { const active = tasks.filter( (t) => t.status === "pending" || t.status === "downloading", ); setOllamaTasks(active); if (active.length > 0) { startOllamaPolling(); } }) .catch(() => {}); return () => stopOllamaPolling(); }, [ open, provider.id, fetchOllamaModels, form, startOllamaPolling, stopOllamaPolling, ]); // --- Remote provider logic --- // For custom providers ALL models are deletable. // For built-in providers only extra_models are deletable. const extraModelIds = new Set( provider.is_custom ? provider.models.map((m) => m.id) : (provider.extra_models || []).map((m) => m.id), ); const handleAddModel = async () => { try { const values = await form.validateFields(); setSaving(true); const id = values.id.trim(); const name = values.name?.trim() || id; await api.addModel(provider.id, { id, name }); message.success(t("models.modelAdded", { name })); form.resetFields(); setAdding(false); onSaved(); } catch (error) { if (error && typeof error === "object" && "errorFields" in error) return; const errMsg = error instanceof Error ? error.message : t("models.modelAddFailed"); message.error(errMsg); } finally { setSaving(false); } }; const handleRemoveModel = (modelId: string, modelName: string) => { Modal.confirm({ title: t("models.removeModel"), content: t("models.removeModelConfirm", { name: modelName, provider: provider.name, }), okText: t("common.delete"), okButtonProps: { danger: true }, cancelText: t("models.cancel"), onOk: async () => { try { await api.removeModel(provider.id, modelId); message.success(t("models.modelRemoved", { name: modelName })); onSaved(); } catch (error) { const errMsg = error instanceof Error ? error.message : t("models.modelRemoveFailed"); message.error(errMsg); } }, }); }; // --- Local provider: download & delete --- const handleDownload = async () => { try { const values = await form.validateFields(); const task = await api.downloadModel({ repo_id: values.repo_id.trim(), filename: values.filename?.trim() || undefined, backend: provider.id, source: values.source || "huggingface", }); setActiveTasks((prev) => [...prev, task]); setAdding(false); form.resetFields(); startPolling(); } catch (error) { if (error && typeof error === "object" && "errorFields" in error) return; const errMsg = error instanceof Error ? error.message : t("models.downloadFailed"); message.error(errMsg); } }; // --- Ollama provider: download & delete --- const handleOllamaDownload = async () => { try { const values = await form.validateFields(); const task = await api.downloadOllamaModel({ name: values.name.trim() }); setOllamaTasks((prev) => [...prev, task]); setAdding(false); form.resetFields(); startOllamaPolling(); } catch (error) { if (error && typeof error === "object" && "errorFields" in error) return; const errMsg = error instanceof Error ? error.message : t("models.downloadFailed"); message.error(errMsg); } }; const handleOllamaDelete = (model: OllamaModelResponse) => { Modal.confirm({ title: t("models.localDeleteModel"), content: t("models.localDeleteConfirm", { name: model.name }), okText: t("common.delete"), okButtonProps: { danger: true }, cancelText: t("models.cancel"), onOk: async () => { try { await api.deleteOllamaModel(model.name); message.success(t("models.localModelDeleted", { name: model.name })); onSaved(); fetchOllamaModels(); } catch (error) { const errMsg = error instanceof Error ? error.message : t("models.localDeleteFailed"); message.error(errMsg); } }, }); }; const handleDeleteLocal = (model: LocalModelResponse) => { Modal.confirm({ title: t("models.localDeleteModel"), content: t("models.localDeleteConfirm", { name: model.display_name }), okText: t("common.delete"), okButtonProps: { danger: true }, cancelText: t("models.cancel"), onOk: async () => { try { await api.deleteLocalModel(model.id); message.success( t("models.localModelDeleted", { name: model.display_name }), ); onSaved(); fetchLocalModels(); } catch (error) { const errMsg = error instanceof Error ? error.message : t("models.localDeleteFailed"); message.error(errMsg); } }, }); }; const handleCancelOllamaDownload = (task: OllamaDownloadTaskResponse) => { Modal.confirm({ title: t("models.localCancelDownload"), content: t("models.localCancelDownloadConfirm", { repo: task.name }), okText: t("models.localCancelDownload"), okButtonProps: { danger: true }, cancelText: t("models.cancel"), onOk: async () => { try { await api.cancelOllamaDownload(task.task_id); message.success(t("models.localDownloadCancelled")); // Remove from active tasks immediately setOllamaTasks((prev) => prev.filter((t) => t.task_id !== task.task_id), ); } catch (error) { const errMsg = error instanceof Error ? error.message : t("models.localCancelDownloadFailed"); message.error(errMsg); } }, }); }; const handleCancelDownload = (task: DownloadTaskResponse) => { Modal.confirm({ title: t("models.localCancelDownload"), content: t("models.localCancelDownloadConfirm", { repo: task.repo_id }), okText: t("models.localCancelDownload"), okButtonProps: { danger: true }, cancelText: t("models.cancel"), onOk: async () => { try { await api.cancelDownload(task.task_id); message.success(t("models.localDownloadCancelled")); // Remove from active tasks immediately setActiveTasks((prev) => prev.filter((t) => t.task_id !== task.task_id), ); } catch (error) { const errMsg = error instanceof Error ? error.message : t("models.localCancelDownloadFailed"); message.error(errMsg); } }, }); }; const handleClose = () => { setAdding(false); form.resetFields(); onClose(); }; // --- Render: Ollama provider --- if (provider.id === "ollama") { return ( {/* Active download statuses */} {ollamaTasks.map((task) => (
{task.status === "pending" ? t("models.localDownloadPending") : t("models.localDownloading", { repo: task.name })}
))} {/* Downloaded models list */}
{loadingOllama ? (
{t("common.loading")}
) : ollamaModels.length === 0 ? (
{t("models.localNoModels")}
) : ( ollamaModels.map((m) => (
{m.name}
{formatFileSize(m.size)}
)) )}
{/* Download form — always available */} {adding ? (
) : ( )}
); } // --- Render: local provider --- if (provider.is_local) { return ( {/* Active download statuses */} {activeTasks.map((task) => (
{task.status === "pending" ? t("models.downloadPending") : t("models.localDownloading", { repo: task.repo_id })}
))} {/* Downloaded models list */}
{loadingLocal ? (
{t("common.loading")}
) : localModels.length === 0 ? (
{t("models.localNoModels")}
) : ( localModels.map((m) => (
{m.display_name} {m.repo_id}/{m.filename} ·{" "} {formatFileSize(m.file_size)}
{m.source === "huggingface" ? "HF" : "MS"}
)) )}
{/* Download form — always available */} {adding ? (
) : ( )}
); } ================================================ FILE: console/src/pages/Settings/Models/components/cards/LocalProviderCard.tsx ================================================ import { useState } from "react"; import { Card, Button, Tag } from "@agentscope-ai/design"; import { AppstoreOutlined } from "@ant-design/icons"; import type { ProviderInfo } from "../../../../../api/types"; import { ModelManageModal } from "../modals/ModelManageModal"; import { useTranslation } from "react-i18next"; import styles from "../../index.module.less"; interface LocalProviderCardProps { provider: ProviderInfo; onSaved: () => void; isHover: boolean; onMouseEnter: () => void; onMouseLeave: () => void; } export function LocalProviderCard({ provider, onSaved, isHover, onMouseEnter, onMouseLeave, }: LocalProviderCardProps) { const { t } = useTranslation(); const [modelManageOpen, setModelManageOpen] = useState(false); const totalCount = provider.models.length + provider.extra_models.length; const statusReady = totalCount > 0; const statusLabel = statusReady ? t("models.available") : t("models.unavailable"); return (
{provider.name} {t("models.local")}
{statusLabel}
{t("models.localType")}: {t("models.localEmbedded")}
{t("models.model")}: {totalCount > 0 ? t("models.modelsCount", { count: totalCount }) : t("models.localDownloadFirst")}
setModelManageOpen(false)} onSaved={onSaved} />
); } ================================================ FILE: console/src/pages/Settings/Models/components/cards/ProviderCard.tsx ================================================ import type { ProviderInfo, ActiveModelsInfo } from "../../../../../api/types"; import { LocalProviderCard } from "./LocalProviderCard"; import { RemoteProviderCard } from "./RemoteProviderCard"; interface ProviderCardProps { provider: ProviderInfo; activeModels: ActiveModelsInfo | null; onSaved: () => void; isHover: boolean; onMouseEnter: () => void; onMouseLeave: () => void; } export function ProviderCard({ provider, activeModels, onSaved, isHover, onMouseEnter, onMouseLeave, }: ProviderCardProps) { if (provider.is_local) { return ( ); } return ( ); } ================================================ FILE: console/src/pages/Settings/Models/components/cards/RemoteProviderCard.tsx ================================================ import { useState } from "react"; import { Card, Button, Tag, Modal, message } from "@agentscope-ai/design"; import { EditOutlined, DeleteOutlined, AppstoreOutlined, } from "@ant-design/icons"; import type { ProviderInfo, ActiveModelsInfo } from "../../../../../api/types"; import { ProviderConfigModal } from "../modals/ProviderConfigModal"; import { ModelManageModal } from "../modals/ModelManageModal"; import api from "../../../../../api"; import { useTranslation } from "react-i18next"; import styles from "../../index.module.less"; interface RemoteProviderCardProps { provider: ProviderInfo; activeModels: ActiveModelsInfo | null; onSaved: () => void; isHover: boolean; onMouseEnter: () => void; onMouseLeave: () => void; } export function RemoteProviderCard({ provider, activeModels, onSaved, isHover, onMouseEnter, onMouseLeave, }: RemoteProviderCardProps) { const { t } = useTranslation(); const [modalOpen, setModalOpen] = useState(false); const [modelManageOpen, setModelManageOpen] = useState(false); const handleDeleteProvider = (e: React.MouseEvent) => { e.stopPropagation(); Modal.confirm({ title: t("models.deleteProvider"), content: t("models.deleteProviderConfirm", { name: provider.name }), okText: t("common.delete"), okButtonProps: { danger: true }, cancelText: t("models.cancel"), onOk: async () => { try { await api.deleteCustomProvider(provider.id); message.success(t("models.providerDeleted", { name: provider.name })); onSaved(); } catch (error) { const errMsg = error instanceof Error ? error.message : t("models.providerDeleteFailed"); message.error(errMsg); } }, }); }; const totalCount = provider.models.length + provider.extra_models.length; let isConfigured = false; if (provider.is_local) { isConfigured = true; } else if (provider.is_custom && provider.base_url) { isConfigured = true; } else if (provider.require_api_key === false) { isConfigured = true; } else if (provider.require_api_key && provider.api_key) { isConfigured = true; } const hasModels = totalCount > 0; const isAvailable = isConfigured && hasModels; const providerTag = provider.is_custom ? ( {t("models.custom")} ) : ( {t("models.builtin")} ); const statusLabel = isAvailable ? t("models.providerAvailable") : isConfigured ? t("models.providerNoModels") : t("models.providerNotConfigured"); const statusType = isAvailable ? "enabled" : isConfigured ? "partial" : "disabled"; const statusDotColor = isAvailable ? "#52c41a" : isConfigured ? "#faad14" : "#d9d9d9"; const statusDotShadow = isAvailable ? "0 0 0 2px rgba(82, 196, 26, 0.2)" : isConfigured ? "0 0 0 2px rgba(250, 173, 20, 0.2)" : "none"; return (
{provider.name} {providerTag}
{statusLabel}
{t("models.baseURL")}: {provider.base_url ? ( {provider.base_url} ) : ( {t("models.notSet")} )}
{t("models.apiKey")}: {provider.api_key ? ( {provider.api_key} ) : ( {t("models.notSet")} )}
{t("models.model")}: {totalCount > 0 ? t("models.modelsCount", { count: totalCount }) : t("models.noModels")}
{provider.is_custom && ( )}
setModalOpen(false)} onSaved={onSaved} /> setModelManageOpen(false)} onSaved={onSaved} />
); } ================================================ FILE: console/src/pages/Settings/Models/components/cards/index.ts ================================================ export * from "./ProviderCard"; export * from "./LocalProviderCard"; export * from "./RemoteProviderCard"; ================================================ FILE: console/src/pages/Settings/Models/components/index.ts ================================================ // Re-export all components from subdirectories export * from "./sections"; export * from "./cards"; export * from "./modals"; ================================================ FILE: console/src/pages/Settings/Models/components/modals/CustomProviderModal.tsx ================================================ import { useState, useEffect } from "react"; import { Form, Input, Modal, Select, message } from "@agentscope-ai/design"; import api from "../../../../../api"; import { useTranslation } from "react-i18next"; interface CustomProviderModalProps { open: boolean; onClose: () => void; onSaved: () => void; } export function CustomProviderModal({ open, onClose, onSaved, }: CustomProviderModalProps) { const { t } = useTranslation(); const [saving, setSaving] = useState(false); const [form] = Form.useForm(); useEffect(() => { if (open) { form.resetFields(); } }, [open, form]); const handleSubmit = async () => { try { const values = await form.validateFields(); setSaving(true); await api.createCustomProvider({ id: values.id.trim(), name: values.name.trim(), default_base_url: values.default_base_url?.trim() || "", api_key_prefix: values.api_key_prefix?.trim() || "", chat_model: values.chat_model || "OpenAIChatModel", }); message.success( t("models.providerCreated", { name: values.name.trim() }), ); onSaved(); onClose(); } catch (error) { if (error && typeof error === "object" && "errorFields" in error) return; const errMsg = error instanceof Error ? error.message : t("models.providerCreateFailed"); message.error(errMsg); } finally { setSaving(false); } }; return (
) : (
)}
); } ================================================ FILE: console/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx ================================================ import { useState, useEffect, useMemo, useRef } from "react"; import type { KeyboardEvent, ReactNode, UIEvent } from "react"; import { Form, Input, Modal, message, Button, Select, } from "@agentscope-ai/design"; import { ApiOutlined, DownOutlined, RightOutlined } from "@ant-design/icons"; import type { ProviderConfigRequest } from "../../../../../api/types"; import api from "../../../../../api"; import { useTranslation } from "react-i18next"; import styles from "../../index.module.less"; interface ProviderConfigFormValues extends Omit { generate_kwargs_text?: string; } interface JsonCodeEditorProps { value?: string; onChange?: (value: string) => void; placeholder?: string; rows?: number; } function highlightJson(text: string): ReactNode[] { const tokens: ReactNode[] = []; const pattern = /("(?:\\.|[^"\\])*")(\s*:)?|\btrue\b|\bfalse\b|\bnull\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|[{}\[\],:]/g; let lastIndex = 0; let match: RegExpExecArray | null; while ((match = pattern.exec(text)) !== null) { const [token, stringToken, keySuffix] = match; if (match.index > lastIndex) { tokens.push(text.slice(lastIndex, match.index)); } if (stringToken) { tokens.push( {token} , ); } else if (token === "true" || token === "false") { tokens.push( {token} , ); } else if (token === "null") { tokens.push( {token} , ); } else if (/^-?\d/.test(token)) { tokens.push( {token} , ); } else { tokens.push( {token} , ); } lastIndex = match.index + token.length; } if (lastIndex < text.length) { tokens.push(text.slice(lastIndex)); } return tokens; } function JsonCodeEditor({ value = "", onChange, placeholder, rows = 8, }: JsonCodeEditorProps) { const indentUnit = " "; const highlightRef = useRef(null); const textareaRef = useRef(null); const handleScroll = (event: UIEvent) => { if (!highlightRef.current) { return; } highlightRef.current.scrollTop = event.currentTarget.scrollTop; highlightRef.current.scrollLeft = event.currentTarget.scrollLeft; }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Tab") { return; } event.preventDefault(); const textarea = event.currentTarget; const selectionStart = textarea.selectionStart; const selectionEnd = textarea.selectionEnd; const hasSelection = selectionStart !== selectionEnd; const selectedText = value.slice(selectionStart, selectionEnd); if (!hasSelection || !selectedText.includes("\n")) { if (event.shiftKey) { const lineStart = value.lastIndexOf("\n", selectionStart - 1) + 1; const linePrefix = value.slice(lineStart, selectionStart); if (!linePrefix.endsWith(indentUnit)) { return; } const nextValue = value.slice(0, selectionStart - indentUnit.length) + value.slice(selectionStart); onChange?.(nextValue); requestAnimationFrame(() => { textareaRef.current?.setSelectionRange( selectionStart - indentUnit.length, selectionStart - indentUnit.length, ); }); return; } const nextValue = value.slice(0, selectionStart) + indentUnit + value.slice(selectionEnd); onChange?.(nextValue); requestAnimationFrame(() => { const nextCursor = selectionStart + indentUnit.length; textareaRef.current?.setSelectionRange(nextCursor, nextCursor); }); return; } const lineStart = value.lastIndexOf("\n", selectionStart - 1) + 1; const block = value.slice(lineStart, selectionEnd); const lines = block.split("\n"); if (event.shiftKey) { const updatedLines = lines.map((line) => line.startsWith(indentUnit) ? line.slice(indentUnit.length) : line, ); const removedFromFirstLine = lines[0].startsWith(indentUnit) ? indentUnit.length : 0; const removedTotal = lines.reduce( (total, line) => total + (line.startsWith(indentUnit) ? indentUnit.length : 0), 0, ); const nextValue = value.slice(0, lineStart) + updatedLines.join("\n") + value.slice(selectionEnd); onChange?.(nextValue); requestAnimationFrame(() => { textareaRef.current?.setSelectionRange( selectionStart - removedFromFirstLine, selectionEnd - removedTotal, ); }); return; } const updatedLines = lines.map((line) => `${indentUnit}${line}`); const nextValue = value.slice(0, lineStart) + updatedLines.join("\n") + value.slice(selectionEnd); onChange?.(nextValue); requestAnimationFrame(() => { textareaRef.current?.setSelectionRange( selectionStart + indentUnit.length, selectionEnd + indentUnit.length * lines.length, ); }); }; return (