mirror of
https://github.com/OpenBMB/ChatDev.git
synced 2026-05-26 18:34:02 +00:00
Merge pull request #584 from NA-Wen/main
Enable OpenClaw Integration with ChatDev Backend Workflow Support
This commit is contained in:
commit
34ecbe5b1c
2
Makefile
2
Makefile
@ -10,7 +10,7 @@ dev: ## Run both backend and frontend development servers
|
|||||||
.PHONY: server
|
.PHONY: server
|
||||||
server: ## Start the backend server in the background
|
server: ## Start the backend server in the background
|
||||||
@echo "Starting server in background..."
|
@echo "Starting server in background..."
|
||||||
@uv run python server_main.py --port 6400 --reload &
|
@uv run python server_main.py --port 6400 &
|
||||||
|
|
||||||
.PHONY: client
|
.PHONY: client
|
||||||
client: ## Start the frontend development server
|
client: ## Start the frontend development server
|
||||||
|
|||||||
27
README-zh.md
27
README-zh.md
@ -170,6 +170,33 @@ make dev
|
|||||||
```
|
```
|
||||||
检查所有 YAML 文件的语法与 schema 错误。
|
检查所有 YAML 文件的语法与 schema 错误。
|
||||||
|
|
||||||
|
### 🦞 使用 OpenClaw 运行
|
||||||
|
|
||||||
|
OpenClaw 可以与 ChatDev 集成,通过 **调用已有的 agent 团队**,或在 ChatDev 中 **动态创建新的 agent 团队** 来完成任务。
|
||||||
|
|
||||||
|
开始使用:
|
||||||
|
|
||||||
|
1. 启动 ChatDev 2.0 后端。
|
||||||
|
2. 为你的 OpenClaw 实例安装所需的技能:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdhub install chatdev
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 让 OpenClaw 创建一个 ChatDev 工作流。例如:
|
||||||
|
|
||||||
|
* **自动化信息收集与内容发布**
|
||||||
|
|
||||||
|
```
|
||||||
|
创建一个 ChatDev 工作流,用于自动收集热点信息,生成一篇小红书文案,并发布该内容
|
||||||
|
```
|
||||||
|
|
||||||
|
* **多智能体地缘政治模拟**
|
||||||
|
|
||||||
|
```
|
||||||
|
创建一个 ChatDev 工作流,构建多个 agent,用于模拟中东局势未来可能的发展
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
### 🐳 使用 Docker 运行
|
### 🐳 使用 Docker 运行
|
||||||
你也可以通过 Docker Compose 运行整个应用。该方式可简化依赖管理,并提供一致的运行环境。
|
你也可以通过 Docker Compose 运行整个应用。该方式可简化依赖管理,并提供一致的运行环境。
|
||||||
|
|||||||
23
README.md
23
README.md
@ -182,6 +182,29 @@ make dev
|
|||||||
```
|
```
|
||||||
Checks all YAML files for syntax and schema errors.
|
Checks all YAML files for syntax and schema errors.
|
||||||
|
|
||||||
|
### 🦞 Run with OpenClaw
|
||||||
|
OpenClaw can integrate with ChatDev by invoking existing agent teams or dynamically creating new agent teams within ChatDev.
|
||||||
|
To get started:
|
||||||
|
1. Start the ChatDev 2.0 backend.
|
||||||
|
2. Install the required skills for your OpenClaw instance:
|
||||||
|
```bash
|
||||||
|
clawdhub install chatdev
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Ask your OpenClaw to create a ChatDev workflow. For example:
|
||||||
|
|
||||||
|
* **Automated information collection and content publishing**
|
||||||
|
|
||||||
|
```
|
||||||
|
Create a ChatDev workflow to automatically collect trending information, generate a Xiaohongshu post, and publish it.
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Multi-agent geopolitical simulation**
|
||||||
|
```
|
||||||
|
Create a ChatDev workflow with multiple agents to simulate possible future developments of the Middle East situation.
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
### 🐳 Run with Docker
|
### 🐳 Run with Docker
|
||||||
Alternatively, you can run the entire application using Docker Compose. This method simplifies dependency management and provides a consistent environment.
|
Alternatively, you can run the entire application using Docker Compose. This method simplifies dependency management and provides a consistent environment.
|
||||||
|
|
||||||
|
|||||||
@ -312,6 +312,8 @@ class McpRemoteConfig(BaseConfig):
|
|||||||
server: str
|
server: str
|
||||||
headers: Dict[str, str] = field(default_factory=dict)
|
headers: Dict[str, str] = field(default_factory=dict)
|
||||||
timeout: float | None = None
|
timeout: float | None = None
|
||||||
|
cache_ttl: float = 0.0
|
||||||
|
tool_sources: List[str] | None = None
|
||||||
|
|
||||||
FIELD_SPECS = {
|
FIELD_SPECS = {
|
||||||
"server": ConfigFieldSpec(
|
"server": ConfigFieldSpec(
|
||||||
@ -337,6 +339,22 @@ class McpRemoteConfig(BaseConfig):
|
|||||||
description="Per-request timeout in seconds",
|
description="Per-request timeout in seconds",
|
||||||
advance=True,
|
advance=True,
|
||||||
),
|
),
|
||||||
|
"cache_ttl": ConfigFieldSpec(
|
||||||
|
name="cache_ttl",
|
||||||
|
display_name="Tool Cache TTL",
|
||||||
|
type_hint="float",
|
||||||
|
required=False,
|
||||||
|
description="Seconds to cache MCP tool list; 0 disables cache for hot updates",
|
||||||
|
advance=True,
|
||||||
|
),
|
||||||
|
"tool_sources": ConfigFieldSpec(
|
||||||
|
name="tool_sources",
|
||||||
|
display_name="Tool Sources Filter",
|
||||||
|
type_hint="list[str]",
|
||||||
|
required=False,
|
||||||
|
description="Only include MCP tools whose meta.source is in this list; omit to default to ['mcp_tools'].",
|
||||||
|
advance=True,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -360,7 +378,40 @@ class McpRemoteConfig(BaseConfig):
|
|||||||
else:
|
else:
|
||||||
raise ConfigError("timeout must be numeric", extend_path(path, "timeout"))
|
raise ConfigError("timeout must be numeric", extend_path(path, "timeout"))
|
||||||
|
|
||||||
return cls(server=server, headers=headers, timeout=timeout, path=path)
|
cache_ttl_value = mapping.get("cache_ttl", 0.0)
|
||||||
|
if cache_ttl_value is None:
|
||||||
|
cache_ttl = 0.0
|
||||||
|
elif isinstance(cache_ttl_value, (int, float)):
|
||||||
|
cache_ttl = float(cache_ttl_value)
|
||||||
|
else:
|
||||||
|
raise ConfigError("cache_ttl must be numeric", extend_path(path, "cache_ttl"))
|
||||||
|
|
||||||
|
tool_sources_raw = mapping.get("tool_sources")
|
||||||
|
tool_sources: List[str] | None = None
|
||||||
|
if tool_sources_raw is not None:
|
||||||
|
entries = ensure_list(tool_sources_raw)
|
||||||
|
normalized: List[str] = []
|
||||||
|
for idx, entry in enumerate(entries):
|
||||||
|
if not isinstance(entry, str):
|
||||||
|
raise ConfigError(
|
||||||
|
"tool_sources must be a list of strings",
|
||||||
|
extend_path(path, f"tool_sources[{idx}]"),
|
||||||
|
)
|
||||||
|
value = entry.strip()
|
||||||
|
if value:
|
||||||
|
normalized.append(value)
|
||||||
|
tool_sources = normalized
|
||||||
|
else:
|
||||||
|
tool_sources = ["mcp_tools"]
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
server=server,
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout,
|
||||||
|
cache_ttl=cache_ttl,
|
||||||
|
tool_sources=tool_sources,
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
|
||||||
def cache_key(self) -> str:
|
def cache_key(self) -> str:
|
||||||
payload = (
|
payload = (
|
||||||
@ -380,6 +431,7 @@ class McpLocalConfig(BaseConfig):
|
|||||||
inherit_env: bool = True
|
inherit_env: bool = True
|
||||||
startup_timeout: float = 10.0
|
startup_timeout: float = 10.0
|
||||||
wait_for_log: str | None = None
|
wait_for_log: str | None = None
|
||||||
|
cache_ttl: float = 0.0
|
||||||
|
|
||||||
FIELD_SPECS = {
|
FIELD_SPECS = {
|
||||||
"command": ConfigFieldSpec(
|
"command": ConfigFieldSpec(
|
||||||
@ -438,6 +490,14 @@ class McpLocalConfig(BaseConfig):
|
|||||||
description="Regex that marks readiness when matched against stdout",
|
description="Regex that marks readiness when matched against stdout",
|
||||||
advance=True,
|
advance=True,
|
||||||
),
|
),
|
||||||
|
"cache_ttl": ConfigFieldSpec(
|
||||||
|
name="cache_ttl",
|
||||||
|
display_name="Tool Cache TTL",
|
||||||
|
type_hint="float",
|
||||||
|
required=False,
|
||||||
|
description="Seconds to cache MCP tool list; 0 disables cache for hot updates",
|
||||||
|
advance=True,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -474,6 +534,13 @@ class McpLocalConfig(BaseConfig):
|
|||||||
raise ConfigError("startup_timeout must be numeric", extend_path(path, "startup_timeout"))
|
raise ConfigError("startup_timeout must be numeric", extend_path(path, "startup_timeout"))
|
||||||
|
|
||||||
wait_for_log = optional_str(mapping, "wait_for_log", path)
|
wait_for_log = optional_str(mapping, "wait_for_log", path)
|
||||||
|
cache_ttl_value = mapping.get("cache_ttl", 0.0)
|
||||||
|
if cache_ttl_value is None:
|
||||||
|
cache_ttl = 0.0
|
||||||
|
elif isinstance(cache_ttl_value, (int, float)):
|
||||||
|
cache_ttl = float(cache_ttl_value)
|
||||||
|
else:
|
||||||
|
raise ConfigError("cache_ttl must be numeric", extend_path(path, "cache_ttl"))
|
||||||
return cls(
|
return cls(
|
||||||
command=command,
|
command=command,
|
||||||
args=normalized_args,
|
args=normalized_args,
|
||||||
@ -482,6 +549,7 @@ class McpLocalConfig(BaseConfig):
|
|||||||
inherit_env=bool(inherit_env),
|
inherit_env=bool(inherit_env),
|
||||||
startup_timeout=startup_timeout,
|
startup_timeout=startup_timeout,
|
||||||
wait_for_log=wait_for_log,
|
wait_for_log=wait_for_log,
|
||||||
|
cache_ttl=cache_ttl,
|
||||||
path=path,
|
path=path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
@ -1114,7 +1114,8 @@
|
|||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/markdown-it": {
|
"node_modules/@types/markdown-it": {
|
||||||
"version": "14.1.2",
|
"version": "14.1.2",
|
||||||
@ -1131,7 +1132,8 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/web-bluetooth": {
|
"node_modules/@types/web-bluetooth": {
|
||||||
"version": "0.0.20",
|
"version": "0.0.20",
|
||||||
@ -1181,7 +1183,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.48.1.tgz",
|
||||||
"integrity": "sha512-3IxaMBLvWRbznZ4CuK0kVhp4Y4lCDQx9nhi48Swp6PwPw29KNhmiKd2kaBogYeWjGLb/tLjlE9V0s3jEmKCYWw==",
|
"integrity": "sha512-3IxaMBLvWRbznZ4CuK0kVhp4Y4lCDQx9nhi48Swp6PwPw29KNhmiKd2kaBogYeWjGLb/tLjlE9V0s3jEmKCYWw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vueuse/core": "^10.5.0",
|
"@vueuse/core": "^10.5.0",
|
||||||
"d3-drag": "^3.0.0",
|
"d3-drag": "^3.0.0",
|
||||||
@ -1419,7 +1420,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@ -1664,7 +1664,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
@ -1811,7 +1810,6 @@
|
|||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@ -2309,7 +2307,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||||
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
|
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1",
|
"argparse": "^2.0.1",
|
||||||
"entities": "^4.4.0",
|
"entities": "^4.4.0",
|
||||||
@ -2491,7 +2488,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@ -2755,7 +2751,6 @@
|
|||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@ -2830,7 +2825,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
|
||||||
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
|
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.26",
|
"@vue/compiler-dom": "3.5.26",
|
||||||
"@vue/compiler-sfc": "3.5.26",
|
"@vue/compiler-sfc": "3.5.26",
|
||||||
|
|||||||
@ -70,7 +70,7 @@
|
|||||||
>
|
>
|
||||||
<CollapsibleMessage
|
<CollapsibleMessage
|
||||||
v-if="message.text"
|
v-if="message.text"
|
||||||
:html-content="renderMarkdown(message.text)"
|
:html-content="message.htmlContent || renderMarkdown(message.text)"
|
||||||
:raw-content="message.text"
|
:raw-content="message.text"
|
||||||
:default-expanded="configStore.AUTO_EXPAND_MESSAGES"
|
:default-expanded="configStore.AUTO_EXPAND_MESSAGES"
|
||||||
/>
|
/>
|
||||||
@ -556,6 +556,7 @@ const addTotalLoadingMessage = (nodeId) => {
|
|||||||
type: 'dialogue',
|
type: 'dialogue',
|
||||||
name: nodeId,
|
name: nodeId,
|
||||||
text: '',
|
text: '',
|
||||||
|
htmlContent: '',
|
||||||
avatar,
|
avatar,
|
||||||
isRight: false,
|
isRight: false,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
@ -597,6 +598,10 @@ const addLoadingEntry = (nodeId, baseKey, label) => {
|
|||||||
nodeState.entryMap.set(key, entry)
|
nodeState.entryMap.set(key, entry)
|
||||||
nodeState.baseKeyToKey.set(baseKey, key)
|
nodeState.baseKeyToKey.set(baseKey, key)
|
||||||
nodeState.message.loadingEntries.push(entry)
|
nodeState.message.loadingEntries.push(entry)
|
||||||
|
runningLoadingEntries.value += 1
|
||||||
|
if (runningLoadingEntries.value === 1) {
|
||||||
|
startLoadingTimer()
|
||||||
|
}
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -609,27 +614,56 @@ const finishLoadingEntry = (nodeId, baseKey) => {
|
|||||||
const entry = key ? nodeState.entryMap.get(key) : null
|
const entry = key ? nodeState.entryMap.get(key) : null
|
||||||
if (!entry) return null
|
if (!entry) return null
|
||||||
|
|
||||||
|
const wasRunning = entry.status === 'running'
|
||||||
entry.status = 'done'
|
entry.status = 'done'
|
||||||
entry.endedAt = Date.now()
|
entry.endedAt = Date.now()
|
||||||
nodeState.baseKeyToKey.delete(baseKey)
|
nodeState.baseKeyToKey.delete(baseKey)
|
||||||
|
if (wasRunning) {
|
||||||
|
runningLoadingEntries.value = Math.max(0, runningLoadingEntries.value - 1)
|
||||||
|
if (runningLoadingEntries.value === 0) {
|
||||||
|
stopLoadingTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finish all running entries when a node ends or cancels
|
// Finish all running entries when a node ends or cancels
|
||||||
const finalizeAllLoadingEntries = (nodeState, endedAt = Date.now()) => {
|
const finalizeAllLoadingEntries = (nodeState, endedAt = Date.now()) => {
|
||||||
if (!nodeState) return
|
if (!nodeState) return
|
||||||
|
let finishedCount = 0
|
||||||
for (const entry of nodeState.entryMap.values()) {
|
for (const entry of nodeState.entryMap.values()) {
|
||||||
if (entry.status === 'running') {
|
if (entry.status === 'running') {
|
||||||
entry.status = 'done'
|
entry.status = 'done'
|
||||||
entry.endedAt = endedAt
|
entry.endedAt = endedAt
|
||||||
|
finishedCount += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
nodeState.baseKeyToKey.clear()
|
nodeState.baseKeyToKey.clear()
|
||||||
|
if (finishedCount) {
|
||||||
|
runningLoadingEntries.value = Math.max(0, runningLoadingEntries.value - finishedCount)
|
||||||
|
if (runningLoadingEntries.value === 0) {
|
||||||
|
stopLoadingTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global timer for updating loading bubble durations
|
// Global timer for updating loading bubble durations
|
||||||
const now = ref(Date.now())
|
const now = ref(Date.now())
|
||||||
let loadingTimerInterval = null
|
let loadingTimerInterval = null
|
||||||
|
const runningLoadingEntries = ref(0)
|
||||||
|
|
||||||
|
const startLoadingTimer = () => {
|
||||||
|
if (loadingTimerInterval) return
|
||||||
|
loadingTimerInterval = setInterval(() => {
|
||||||
|
now.value = Date.now()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopLoadingTimer = () => {
|
||||||
|
if (!loadingTimerInterval) return
|
||||||
|
clearInterval(loadingTimerInterval)
|
||||||
|
loadingTimerInterval = null
|
||||||
|
}
|
||||||
|
|
||||||
// Map sprites for different roles
|
// Map sprites for different roles
|
||||||
const nameToSpriteMap = ref(new Map())
|
const nameToSpriteMap = ref(new Map())
|
||||||
@ -878,10 +912,12 @@ const addDialogue = (name, message) => {
|
|||||||
|
|
||||||
const isRight = name === "User"
|
const isRight = name === "User"
|
||||||
|
|
||||||
|
const htmlContent = renderMarkdown(text)
|
||||||
chatMessages.value.push({
|
chatMessages.value.push({
|
||||||
type: 'dialogue',
|
type: 'dialogue',
|
||||||
name: name,
|
name: name,
|
||||||
text: text,
|
text: text,
|
||||||
|
htmlContent,
|
||||||
avatar: avatar,
|
avatar: avatar,
|
||||||
isRight: isRight,
|
isRight: isRight,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
@ -1499,13 +1535,6 @@ onMounted(() => {
|
|||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
document.addEventListener('keydown', handleKeydown)
|
document.addEventListener('keydown', handleKeydown)
|
||||||
loadWorkflows()
|
loadWorkflows()
|
||||||
|
|
||||||
// Start the global timer
|
|
||||||
if (!loadingTimerInterval) {
|
|
||||||
loadingTimerInterval = setInterval(() => {
|
|
||||||
now.value = Date.now()
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@ -1515,10 +1544,8 @@ onUnmounted(() => {
|
|||||||
resetConnectionState()
|
resetConnectionState()
|
||||||
cleanupRecording()
|
cleanupRecording()
|
||||||
|
|
||||||
if (loadingTimerInterval) {
|
stopLoadingTimer()
|
||||||
clearInterval(loadingTimerInterval)
|
runningLoadingEntries.value = 0
|
||||||
loadingTimerInterval = null
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const { fromObject, fitView, onPaneReady, onNodesInitialized, setNodes, setEdges, edges } = useVueFlow()
|
const { fromObject, fitView, onPaneReady, onNodesInitialized, setNodes, setEdges, edges } = useVueFlow()
|
||||||
|
|||||||
@ -69,7 +69,7 @@ export async function postYaml(filename, content) {
|
|||||||
export async function updateYaml(filename, content) {
|
export async function updateYaml(filename, content) {
|
||||||
try {
|
try {
|
||||||
const yamlFilename = addYamlSuffix(filename)
|
const yamlFilename = addYamlSuffix(filename)
|
||||||
const response = await fetch(apiUrl(`/api/workflows/${encodeURIComponent(yamlFilename)}`), {
|
const response = await fetch(apiUrl(`/api/workflows/${encodeURIComponent(yamlFilename)}/update`), {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@ -197,7 +197,7 @@ export async function fetchWorkflowsWithDesc() {
|
|||||||
const filesWithDesc = await Promise.all(
|
const filesWithDesc = await Promise.all(
|
||||||
data.workflows.map(async (filename) => {
|
data.workflows.map(async (filename) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(apiUrl(`/api/workflows/${encodeURIComponent(filename)}`))
|
const response = await fetch(apiUrl(`/api/workflows/${encodeURIComponent(filename)}/desc`))
|
||||||
const fileData = await response.json()
|
const fileData = await response.json()
|
||||||
return {
|
return {
|
||||||
name: filename,
|
name: filename,
|
||||||
@ -234,7 +234,7 @@ export async function fetchWorkflowsWithDesc() {
|
|||||||
// Fetch YAML file content
|
// Fetch YAML file content
|
||||||
export async function fetchWorkflowYAML(filename) {
|
export async function fetchWorkflowYAML(filename) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(apiUrl(`/api/workflows/${encodeURIComponent(filename)}`))
|
const response = await fetch(apiUrl(`/api/workflows/${encodeURIComponent(filename)}/get`))
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to load YAML file: ${filename}, status: ${response.status}`)
|
throw new Error(`Failed to load YAML file: ${filename}, status: ${response.status}`)
|
||||||
}
|
}
|
||||||
@ -250,7 +250,7 @@ export async function fetchWorkflowYAML(filename) {
|
|||||||
export async function fetchYaml(filename) {
|
export async function fetchYaml(filename) {
|
||||||
try {
|
try {
|
||||||
const yamlFilename = addYamlSuffix(filename)
|
const yamlFilename = addYamlSuffix(filename)
|
||||||
const response = await fetch(apiUrl(`/api/workflows/${encodeURIComponent(yamlFilename)}`))
|
const response = await fetch(apiUrl(`/api/workflows/${encodeURIComponent(yamlFilename)}/get`))
|
||||||
|
|
||||||
const data = await response.json().catch(() => ({}))
|
const data = await response.json().catch(() => ({}))
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from server.bootstrap import init_app
|
from server.bootstrap import init_app
|
||||||
|
from utils.env_loader import load_dotenv_file
|
||||||
|
|
||||||
|
load_dotenv_file()
|
||||||
|
|
||||||
app = FastAPI(title="DevAll Workflow Server", version="1.0.0")
|
app = FastAPI(title="DevAll Workflow Server", version="1.0.0")
|
||||||
init_app(app)
|
init_app(app)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"""Application bootstrap helpers for the FastAPI server."""
|
"""Application bootstrap helpers for the FastAPI server."""
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from server import state
|
from server import state
|
||||||
from server.config_schema_router import router as config_schema_router
|
from server.config_schema_router import router as config_schema_router
|
||||||
@ -12,6 +13,14 @@ from utils.middleware import add_middleware
|
|||||||
def init_app(app: FastAPI) -> None:
|
def init_app(app: FastAPI) -> None:
|
||||||
"""Apply shared middleware, routers, and global state to ``app``."""
|
"""Apply shared middleware, routers, and global state to ``app``."""
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
add_exception_handlers(app)
|
add_exception_handlers(app)
|
||||||
add_middleware(app)
|
add_middleware(app)
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"""Pydantic models shared across server routes."""
|
"""Pydantic models shared across server routes."""
|
||||||
|
|
||||||
from typing import List, Literal, Optional
|
from typing import Any, Dict, List, Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, constr
|
from pydantic import BaseModel, constr
|
||||||
|
|
||||||
@ -13,6 +13,15 @@ class WorkflowRequest(BaseModel):
|
|||||||
log_level: Literal["INFO", "DEBUG"] = "INFO"
|
log_level: Literal["INFO", "DEBUG"] = "INFO"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowRunRequest(BaseModel):
|
||||||
|
yaml_file: str
|
||||||
|
task_prompt: str
|
||||||
|
attachments: Optional[List[str]] = None
|
||||||
|
session_name: Optional[str] = None
|
||||||
|
variables: Optional[Dict[str, Any]] = None
|
||||||
|
log_level: Optional[Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]] = None
|
||||||
|
|
||||||
|
|
||||||
class WorkflowUploadContentRequest(BaseModel):
|
class WorkflowUploadContentRequest(BaseModel):
|
||||||
filename: str
|
filename: str
|
||||||
content: str
|
content: str
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"""Aggregates API routers."""
|
"""Aggregates API routers."""
|
||||||
|
|
||||||
from . import artifacts, batch, execute, health, sessions, uploads, vuegraphs, workflows, websocket
|
from . import artifacts, execute, execute_sync, health, sessions, uploads, vuegraphs, workflows, websocket, batch, tools
|
||||||
|
|
||||||
ALL_ROUTERS = [
|
ALL_ROUTERS = [
|
||||||
health.router,
|
health.router,
|
||||||
@ -11,7 +11,9 @@ ALL_ROUTERS = [
|
|||||||
sessions.router,
|
sessions.router,
|
||||||
batch.router,
|
batch.router,
|
||||||
execute.router,
|
execute.router,
|
||||||
|
execute_sync.router,
|
||||||
|
tools.router,
|
||||||
websocket.router,
|
websocket.router,
|
||||||
]
|
]
|
||||||
|
|
||||||
__all__ = ["ALL_ROUTERS"]
|
__all__ = ["ALL_ROUTERS"]
|
||||||
253
server/routes/execute_sync.py
Normal file
253
server/routes/execute_sync.py
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional, Sequence, Union
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
from check.check import load_config
|
||||||
|
from entity.enums import LogLevel
|
||||||
|
from entity.graph_config import GraphConfig
|
||||||
|
from entity.messages import Message
|
||||||
|
from runtime.bootstrap.schema import ensure_schema_registry_populated
|
||||||
|
from runtime.sdk import OUTPUT_ROOT, run_workflow
|
||||||
|
from server.models import WorkflowRunRequest
|
||||||
|
from server.settings import YAML_DIR
|
||||||
|
from utils.attachments import AttachmentStore
|
||||||
|
from utils.exceptions import ValidationError, WorkflowExecutionError
|
||||||
|
from utils.logger import WorkflowLogger
|
||||||
|
from utils.structured_logger import get_server_logger, LogType
|
||||||
|
from utils.task_input import TaskInputBuilder
|
||||||
|
from workflow.graph import GraphExecutor
|
||||||
|
from workflow.graph_context import GraphContext
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_SSE_CONTENT_TYPE = "text/event-stream"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_session_name(yaml_path: Path, session_name: Optional[str]) -> str:
|
||||||
|
if session_name and session_name.strip():
|
||||||
|
return session_name.strip()
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||||
|
return f"sdk_{yaml_path.stem}_{timestamp}"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_yaml_path(yaml_file: Union[str, Path]) -> Path:
|
||||||
|
candidate = Path(yaml_file).expanduser()
|
||||||
|
if candidate.is_absolute():
|
||||||
|
return candidate
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate
|
||||||
|
repo_root = Path(__file__).resolve().parents[2]
|
||||||
|
yaml_root = YAML_DIR if YAML_DIR.is_absolute() else (repo_root / YAML_DIR)
|
||||||
|
return (yaml_root / candidate).expanduser()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_task_input(
|
||||||
|
graph_context: GraphContext,
|
||||||
|
prompt: str,
|
||||||
|
attachments: Sequence[Union[str, Path]],
|
||||||
|
) -> Union[str, list[Message]]:
|
||||||
|
if not attachments:
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
attachments_dir = graph_context.directory / "code_workspace" / "attachments"
|
||||||
|
attachments_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
store = AttachmentStore(attachments_dir)
|
||||||
|
builder = TaskInputBuilder(store)
|
||||||
|
normalized_paths = [str(Path(path).expanduser()) for path in attachments]
|
||||||
|
return builder.build_from_file_paths(prompt, normalized_paths)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_workflow_with_logger(
|
||||||
|
*,
|
||||||
|
yaml_file: Union[str, Path],
|
||||||
|
task_prompt: str,
|
||||||
|
attachments: Optional[Sequence[Union[str, Path]]],
|
||||||
|
session_name: Optional[str],
|
||||||
|
variables: Optional[dict],
|
||||||
|
log_level: Optional[LogLevel],
|
||||||
|
log_callback,
|
||||||
|
) -> tuple[Optional[Message], dict[str, Any]]:
|
||||||
|
ensure_schema_registry_populated()
|
||||||
|
|
||||||
|
yaml_path = _resolve_yaml_path(yaml_file)
|
||||||
|
if not yaml_path.exists():
|
||||||
|
raise FileNotFoundError(f"YAML file not found: {yaml_path}")
|
||||||
|
|
||||||
|
attachments = attachments or []
|
||||||
|
if (not task_prompt or not task_prompt.strip()) and not attachments:
|
||||||
|
raise ValidationError(
|
||||||
|
"Task prompt cannot be empty",
|
||||||
|
details={"task_prompt_provided": bool(task_prompt)},
|
||||||
|
)
|
||||||
|
|
||||||
|
design = load_config(yaml_path, vars_override=variables)
|
||||||
|
normalized_session = _normalize_session_name(yaml_path, session_name)
|
||||||
|
|
||||||
|
graph_config = GraphConfig.from_definition(
|
||||||
|
design.graph,
|
||||||
|
name=normalized_session,
|
||||||
|
output_root=OUTPUT_ROOT,
|
||||||
|
source_path=str(yaml_path),
|
||||||
|
vars=design.vars,
|
||||||
|
)
|
||||||
|
|
||||||
|
if log_level:
|
||||||
|
graph_config.log_level = log_level
|
||||||
|
graph_config.definition.log_level = log_level
|
||||||
|
|
||||||
|
graph_context = GraphContext(config=graph_config)
|
||||||
|
task_input = _build_task_input(graph_context, task_prompt, attachments)
|
||||||
|
|
||||||
|
class _StreamingWorkflowLogger(WorkflowLogger):
|
||||||
|
def add_log(self, *args, **kwargs):
|
||||||
|
entry = super().add_log(*args, **kwargs)
|
||||||
|
if entry:
|
||||||
|
payload = entry.to_dict()
|
||||||
|
payload.pop("details", None)
|
||||||
|
log_callback("log", payload)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
class _StreamingExecutor(GraphExecutor):
|
||||||
|
def _create_logger(self) -> WorkflowLogger:
|
||||||
|
level = log_level or self.graph.log_level
|
||||||
|
return _StreamingWorkflowLogger(
|
||||||
|
self.graph.name,
|
||||||
|
level,
|
||||||
|
use_structured_logging=True,
|
||||||
|
log_to_console=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
executor = _StreamingExecutor(graph_context, session_id=normalized_session)
|
||||||
|
executor._execute(task_input)
|
||||||
|
final_message = executor.get_final_output_message()
|
||||||
|
|
||||||
|
logger = executor.log_manager.get_logger() if executor.log_manager else None
|
||||||
|
log_id = logger.workflow_id if logger else None
|
||||||
|
token_usage = executor.token_tracker.get_token_usage() if executor.token_tracker else None
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
"session_name": normalized_session,
|
||||||
|
"yaml_file": str(yaml_path),
|
||||||
|
"log_id": log_id,
|
||||||
|
"token_usage": token_usage,
|
||||||
|
"output_dir": graph_context.directory,
|
||||||
|
}
|
||||||
|
return final_message, meta
|
||||||
|
|
||||||
|
|
||||||
|
def _sse_event(event_type: str, data: Any) -> str:
|
||||||
|
payload = json.dumps(data, ensure_ascii=False, default=str)
|
||||||
|
return f"event: {event_type}\ndata: {payload}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/workflow/run")
|
||||||
|
async def run_workflow_sync(request: WorkflowRunRequest, http_request: Request):
|
||||||
|
try:
|
||||||
|
resolved_log_level: Optional[LogLevel] = None
|
||||||
|
if request.log_level:
|
||||||
|
resolved_log_level = LogLevel(request.log_level)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="log_level must be one of DEBUG, INFO, WARNING, ERROR, CRITICAL",
|
||||||
|
)
|
||||||
|
|
||||||
|
accepts_stream = _SSE_CONTENT_TYPE in (http_request.headers.get("accept") or "")
|
||||||
|
if not accepts_stream:
|
||||||
|
try:
|
||||||
|
result = await run_in_threadpool(
|
||||||
|
run_workflow,
|
||||||
|
request.yaml_file,
|
||||||
|
task_prompt=request.task_prompt,
|
||||||
|
attachments=request.attachments,
|
||||||
|
session_name=request.session_name,
|
||||||
|
variables=request.variables,
|
||||||
|
log_level=resolved_log_level,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc))
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
logger = get_server_logger()
|
||||||
|
logger.log_exception(exc, "Failed to run workflow via sync API")
|
||||||
|
raise WorkflowExecutionError(f"Failed to run workflow: {exc}")
|
||||||
|
|
||||||
|
final_message = result.final_message.text_content() if result.final_message else ""
|
||||||
|
meta = result.meta_info
|
||||||
|
|
||||||
|
logger = get_server_logger()
|
||||||
|
logger.info(
|
||||||
|
"Workflow execution completed via sync API",
|
||||||
|
log_type=LogType.WORKFLOW,
|
||||||
|
session_id=meta.session_name,
|
||||||
|
yaml_path=meta.yaml_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "completed",
|
||||||
|
"final_message": final_message,
|
||||||
|
"token_usage": meta.token_usage,
|
||||||
|
"output_dir": str(meta.output_dir.resolve()),
|
||||||
|
}
|
||||||
|
|
||||||
|
event_queue: queue.Queue[tuple[str, Any]] = queue.Queue()
|
||||||
|
done_event = threading.Event()
|
||||||
|
|
||||||
|
def enqueue(event_type: str, data: Any) -> None:
|
||||||
|
event_queue.put((event_type, data))
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
try:
|
||||||
|
enqueue(
|
||||||
|
"started",
|
||||||
|
{"yaml_file": request.yaml_file, "task_prompt": request.task_prompt},
|
||||||
|
)
|
||||||
|
final_message, meta = _run_workflow_with_logger(
|
||||||
|
yaml_file=request.yaml_file,
|
||||||
|
task_prompt=request.task_prompt,
|
||||||
|
attachments=request.attachments,
|
||||||
|
session_name=request.session_name,
|
||||||
|
variables=request.variables,
|
||||||
|
log_level=resolved_log_level,
|
||||||
|
log_callback=enqueue,
|
||||||
|
)
|
||||||
|
enqueue(
|
||||||
|
"completed",
|
||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"final_message": final_message.text_content() if final_message else "",
|
||||||
|
"token_usage": meta["token_usage"],
|
||||||
|
"output_dir": str(meta["output_dir"].resolve()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, ValidationError) as exc:
|
||||||
|
enqueue("error", {"message": str(exc)})
|
||||||
|
except Exception as exc:
|
||||||
|
logger = get_server_logger()
|
||||||
|
logger.log_exception(exc, "Failed to run workflow via streaming API")
|
||||||
|
enqueue("error", {"message": f"Failed to run workflow: {exc}"})
|
||||||
|
finally:
|
||||||
|
done_event.set()
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
async def stream():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
event_type, data = event_queue.get(timeout=0.1)
|
||||||
|
yield _sse_event(event_type, data)
|
||||||
|
except queue.Empty:
|
||||||
|
if done_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
return StreamingResponse(stream(), media_type=_SSE_CONTENT_TYPE)
|
||||||
74
server/routes/tools.py
Normal file
74
server/routes/tools.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel, constr
|
||||||
|
|
||||||
|
from utils.function_catalog import get_function_catalog
|
||||||
|
from utils.function_manager import FUNCTION_CALLING_DIR
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class LocalToolCreateRequest(BaseModel):
|
||||||
|
filename: constr(strip_whitespace=True, min_length=1, max_length=255)
|
||||||
|
content: str
|
||||||
|
overwrite: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tools/local")
|
||||||
|
def list_local_tools():
|
||||||
|
catalog = get_function_catalog()
|
||||||
|
metadata = catalog.list_metadata()
|
||||||
|
tools = []
|
||||||
|
for name, meta in metadata.items():
|
||||||
|
tools.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"description": meta.description,
|
||||||
|
"parameters": meta.parameters_schema,
|
||||||
|
"module": meta.module_name,
|
||||||
|
"file_path": meta.file_path,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
tools.sort(key=lambda item: item["name"])
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"count": len(tools),
|
||||||
|
"tools": tools,
|
||||||
|
"load_error": str(catalog.load_error) if catalog.load_error else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/tools/local")
|
||||||
|
def create_local_tool(payload: LocalToolCreateRequest):
|
||||||
|
raw_name = payload.filename.strip()
|
||||||
|
if not raw_name:
|
||||||
|
raise HTTPException(status_code=400, detail="filename is required")
|
||||||
|
|
||||||
|
if not re.match(r"^[A-Za-z0-9_-]+(\.py)?$", raw_name):
|
||||||
|
raise HTTPException(status_code=400, detail="filename must be alphanumeric with optional .py extension")
|
||||||
|
|
||||||
|
filename = raw_name if raw_name.endswith(".py") else f"{raw_name}.py"
|
||||||
|
tools_dir = Path(FUNCTION_CALLING_DIR).resolve()
|
||||||
|
tools_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
target_path = (tools_dir / filename).resolve()
|
||||||
|
try:
|
||||||
|
target_path.relative_to(tools_dir)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="filename resolves outside function tools directory")
|
||||||
|
|
||||||
|
if target_path.exists() and not payload.overwrite:
|
||||||
|
raise HTTPException(status_code=409, detail="tool file already exists")
|
||||||
|
|
||||||
|
target_path.write_text(payload.content, encoding="utf-8")
|
||||||
|
|
||||||
|
catalog = get_function_catalog()
|
||||||
|
catalog.refresh()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"filename": filename,
|
||||||
|
"path": str(target_path),
|
||||||
|
"load_error": str(catalog.load_error) if catalog.load_error else None,
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from server.models import (
|
from server.models import (
|
||||||
WorkflowCopyRequest,
|
WorkflowCopyRequest,
|
||||||
@ -67,6 +68,154 @@ async def list_workflows():
|
|||||||
return {"workflows": [file.name for file in YAML_DIR.glob("*.yaml")]}
|
return {"workflows": [file.name for file in YAML_DIR.glob("*.yaml")]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/workflows/{filename}/args")
|
||||||
|
async def get_workflow_args(filename: str):
|
||||||
|
print(str)
|
||||||
|
try:
|
||||||
|
safe_filename = validate_workflow_filename(filename, require_yaml_extension=True)
|
||||||
|
print(safe_filename)
|
||||||
|
file_path = YAML_DIR / safe_filename
|
||||||
|
|
||||||
|
if not file_path.exists() or not file_path.is_file():
|
||||||
|
raise ResourceNotFoundError(
|
||||||
|
"Workflow file not found",
|
||||||
|
resource_type="workflow",
|
||||||
|
resource_id=safe_filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load and validate YAML content
|
||||||
|
raw_content = file_path.read_text(encoding="utf-8")
|
||||||
|
_, yaml_content = validate_workflow_content(safe_filename, raw_content)
|
||||||
|
|
||||||
|
args: list[dict[str, Any]] = []
|
||||||
|
if isinstance(yaml_content, dict):
|
||||||
|
graph = yaml_content.get("graph") or {}
|
||||||
|
if isinstance(graph, dict):
|
||||||
|
raw_args = graph.get("args") or []
|
||||||
|
if isinstance(raw_args, list):
|
||||||
|
if len(raw_args) == 0:
|
||||||
|
raise ResourceNotFoundError(
|
||||||
|
"Workflow file does not have args",
|
||||||
|
resource_type="workflow",
|
||||||
|
resource_id=safe_filename,
|
||||||
|
)
|
||||||
|
for item in raw_args:
|
||||||
|
# Each item is expected to be like: { arg_name: [ {key: value}, ... ] }
|
||||||
|
if not isinstance(item, dict) or len(item) != 1:
|
||||||
|
continue
|
||||||
|
(arg_name, spec_list), = item.items()
|
||||||
|
if not isinstance(arg_name, str):
|
||||||
|
continue
|
||||||
|
|
||||||
|
arg_info: dict[str, Any] = {"name": arg_name}
|
||||||
|
if isinstance(spec_list, list):
|
||||||
|
for spec in spec_list:
|
||||||
|
if isinstance(spec, dict):
|
||||||
|
for key, value in spec.items():
|
||||||
|
# Later entries override earlier ones if duplicated
|
||||||
|
arg_info[str(key)] = value
|
||||||
|
args.append(arg_info)
|
||||||
|
|
||||||
|
logger = get_server_logger()
|
||||||
|
logger.info(
|
||||||
|
"Workflow args retrieved",
|
||||||
|
log_type=LogType.WORKFLOW,
|
||||||
|
filename=safe_filename,
|
||||||
|
args_count=len(args),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"args": args}
|
||||||
|
except ValidationError as exc:
|
||||||
|
# 参数或文件名等校验错误
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={"message": str(exc)},
|
||||||
|
)
|
||||||
|
except SecurityError as exc:
|
||||||
|
# 安全相关错误(例如路径遍历)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={"message": str(exc)},
|
||||||
|
)
|
||||||
|
except ResourceNotFoundError as exc:
|
||||||
|
# 文件不存在
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail={"message": str(exc)},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger = get_server_logger()
|
||||||
|
logger.log_exception(exc, f"Unexpected error retrieving workflow args: {filename}")
|
||||||
|
# 兜底错误
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail={"message": f"Failed to retrieve workflow args: {exc}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/workflows/{filename}/desc")
|
||||||
|
async def get_workflow_desc(filename: str):
|
||||||
|
try:
|
||||||
|
safe_filename = validate_workflow_filename(filename, require_yaml_extension=True)
|
||||||
|
file_path = YAML_DIR / safe_filename
|
||||||
|
|
||||||
|
if not file_path.exists() or not file_path.is_file():
|
||||||
|
raise ResourceNotFoundError(
|
||||||
|
"Workflow file not found",
|
||||||
|
resource_type="workflow",
|
||||||
|
resource_id=safe_filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load and validate YAML content
|
||||||
|
raw_content = file_path.read_text(encoding="utf-8")
|
||||||
|
_, yaml_content = validate_workflow_content(safe_filename, raw_content)
|
||||||
|
|
||||||
|
desc = ""
|
||||||
|
if isinstance(yaml_content, dict):
|
||||||
|
graph = yaml_content.get("graph") or {}
|
||||||
|
if isinstance(graph, dict):
|
||||||
|
desc = graph.get("description") or ""
|
||||||
|
if len(desc) == 0:
|
||||||
|
raise ResourceNotFoundError(
|
||||||
|
"Workflow file does not have args",
|
||||||
|
resource_type="workflow",
|
||||||
|
resource_id=safe_filename,
|
||||||
|
)
|
||||||
|
logger = get_server_logger()
|
||||||
|
logger.info(
|
||||||
|
"Workflow description retrieved",
|
||||||
|
log_type=LogType.WORKFLOW,
|
||||||
|
filename=safe_filename,
|
||||||
|
)
|
||||||
|
return {"description": desc}
|
||||||
|
except ValidationError as exc:
|
||||||
|
# 参数或文件名等校验错误
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={"message": str(exc)},
|
||||||
|
)
|
||||||
|
except SecurityError as exc:
|
||||||
|
# 安全相关错误(例如路径遍历)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={"message": str(exc)},
|
||||||
|
)
|
||||||
|
except ResourceNotFoundError as exc:
|
||||||
|
# 文件不存在
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail={"message": str(exc)},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger = get_server_logger()
|
||||||
|
logger.log_exception(exc, f"Unexpected error retrieving workflow args: {filename}")
|
||||||
|
# 兜底错误
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail={"message": f"Failed to retrieve workflow args: {exc}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/workflows/upload/content")
|
@router.post("/api/workflows/upload/content")
|
||||||
async def upload_workflow_content(request: WorkflowUploadContentRequest):
|
async def upload_workflow_content(request: WorkflowUploadContentRequest):
|
||||||
return _persist_workflow_from_content(
|
return _persist_workflow_from_content(
|
||||||
@ -78,7 +227,7 @@ async def upload_workflow_content(request: WorkflowUploadContentRequest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api/workflows/{filename}")
|
@router.put("/api/workflows/{filename}/update")
|
||||||
async def update_workflow_content(filename: str, request: WorkflowUpdateContentRequest):
|
async def update_workflow_content(filename: str, request: WorkflowUpdateContentRequest):
|
||||||
return _persist_workflow_from_content(
|
return _persist_workflow_from_content(
|
||||||
filename,
|
filename,
|
||||||
@ -89,7 +238,7 @@ async def update_workflow_content(filename: str, request: WorkflowUpdateContentR
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/workflows/{filename}")
|
@router.delete("/api/workflows/{filename}/delete")
|
||||||
async def delete_workflow(filename: str):
|
async def delete_workflow(filename: str):
|
||||||
try:
|
try:
|
||||||
safe_filename = validate_workflow_filename(filename, require_yaml_extension=True)
|
safe_filename = validate_workflow_filename(filename, require_yaml_extension=True)
|
||||||
@ -180,7 +329,7 @@ async def copy_workflow_file(filename: str, request: WorkflowCopyRequest):
|
|||||||
raise WorkflowExecutionError(f"Failed to copy workflow: {exc}")
|
raise WorkflowExecutionError(f"Failed to copy workflow: {exc}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/workflows/{filename}")
|
@router.get("/api/workflows/{filename}/get")
|
||||||
async def get_workflow_raw_content(filename: str):
|
async def get_workflow_raw_content(filename: str):
|
||||||
try:
|
try:
|
||||||
safe_filename = validate_workflow_filename(filename, require_yaml_extension=True)
|
safe_filename = validate_workflow_filename(filename, require_yaml_extension=True)
|
||||||
@ -209,3 +358,4 @@ async def get_workflow_raw_content(filename: str):
|
|||||||
logger = get_server_logger()
|
logger = get_server_logger()
|
||||||
logger.log_exception(exc, f"Unexpected error retrieving workflow: {filename}")
|
logger.log_exception(exc, f"Unexpected error retrieving workflow: {filename}")
|
||||||
raise WorkflowExecutionError(f"Failed to retrieve workflow: {exc}")
|
raise WorkflowExecutionError(f"Failed to retrieve workflow: {exc}")
|
||||||
|
|
||||||
|
|||||||
@ -49,8 +49,6 @@ class WebSocketManager:
|
|||||||
):
|
):
|
||||||
self.active_connections: Dict[str, WebSocket] = {}
|
self.active_connections: Dict[str, WebSocket] = {}
|
||||||
self.connection_timestamps: Dict[str, float] = {}
|
self.connection_timestamps: Dict[str, float] = {}
|
||||||
self.send_locks: Dict[str, asyncio.Lock] = {}
|
|
||||||
self.loop: asyncio.AbstractEventLoop | None = None
|
|
||||||
self.session_store = session_store or WorkflowSessionStore()
|
self.session_store = session_store or WorkflowSessionStore()
|
||||||
self.session_controller = session_controller or SessionExecutionController(self.session_store)
|
self.session_controller = session_controller or SessionExecutionController(self.session_store)
|
||||||
self.attachment_service = attachment_service or AttachmentService()
|
self.attachment_service = attachment_service or AttachmentService()
|
||||||
@ -67,16 +65,10 @@ class WebSocketManager:
|
|||||||
|
|
||||||
async def connect(self, websocket: WebSocket, session_id: Optional[str] = None) -> str:
|
async def connect(self, websocket: WebSocket, session_id: Optional[str] = None) -> str:
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
if self.loop is None:
|
|
||||||
try:
|
|
||||||
self.loop = asyncio.get_running_loop()
|
|
||||||
except RuntimeError:
|
|
||||||
self.loop = None
|
|
||||||
if not session_id:
|
if not session_id:
|
||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
self.active_connections[session_id] = websocket
|
self.active_connections[session_id] = websocket
|
||||||
self.connection_timestamps[session_id] = time.time()
|
self.connection_timestamps[session_id] = time.time()
|
||||||
self.send_locks[session_id] = asyncio.Lock()
|
|
||||||
logging.info("WebSocket connected: %s", session_id)
|
logging.info("WebSocket connected: %s", session_id)
|
||||||
await self.send_message(
|
await self.send_message(
|
||||||
session_id,
|
session_id,
|
||||||
@ -98,8 +90,6 @@ class WebSocketManager:
|
|||||||
del self.active_connections[session_id]
|
del self.active_connections[session_id]
|
||||||
if session_id in self.connection_timestamps:
|
if session_id in self.connection_timestamps:
|
||||||
del self.connection_timestamps[session_id]
|
del self.connection_timestamps[session_id]
|
||||||
if session_id in self.send_locks:
|
|
||||||
del self.send_locks[session_id]
|
|
||||||
self.session_controller.cleanup_session(session_id)
|
self.session_controller.cleanup_session(session_id)
|
||||||
remaining_session = self.session_store.get_session(session_id)
|
remaining_session = self.session_store.get_session(session_id)
|
||||||
if remaining_session and remaining_session.executor is None:
|
if remaining_session and remaining_session.executor is None:
|
||||||
@ -111,12 +101,7 @@ class WebSocketManager:
|
|||||||
if session_id in self.active_connections:
|
if session_id in self.active_connections:
|
||||||
websocket = self.active_connections[session_id]
|
websocket = self.active_connections[session_id]
|
||||||
try:
|
try:
|
||||||
lock = self.send_locks.get(session_id)
|
await websocket.send_text(_encode_ws_message(message))
|
||||||
if lock is None:
|
|
||||||
await websocket.send_text(_encode_ws_message(message))
|
|
||||||
else:
|
|
||||||
async with lock:
|
|
||||||
await websocket.send_text(_encode_ws_message(message))
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
logging.error("Failed to send message to %s: %s", session_id, exc)
|
logging.error("Failed to send message to %s: %s", session_id, exc)
|
||||||
@ -130,13 +115,7 @@ class WebSocketManager:
|
|||||||
else:
|
else:
|
||||||
asyncio.run(self.send_message(session_id, message))
|
asyncio.run(self.send_message(session_id, message))
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
if self.loop and self.loop.is_running():
|
asyncio.run(self.send_message(session_id, message))
|
||||||
asyncio.run_coroutine_threadsafe(
|
|
||||||
self.send_message(session_id, message),
|
|
||||||
self.loop,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
asyncio.run(self.send_message(session_id, message))
|
|
||||||
|
|
||||||
async def broadcast(self, message: Dict[str, Any]) -> None:
|
async def broadcast(self, message: Dict[str, Any]) -> None:
|
||||||
for session_id in list(self.active_connections.keys()):
|
for session_id in list(self.active_connections.keys()):
|
||||||
|
|||||||
@ -18,31 +18,16 @@ from utils.structured_logger import get_server_logger, LogType
|
|||||||
|
|
||||||
|
|
||||||
def _update_workflow_id(content: str, workflow_id: str) -> str:
|
def _update_workflow_id(content: str, workflow_id: str) -> str:
|
||||||
# Pattern to match graph:\n id: <value>
|
pattern = re.compile(r"^(id:\\s*).*$", re.MULTILINE)
|
||||||
pattern = re.compile(r"(graph:\s*\n\s*id:\s*).*$", re.MULTILINE)
|
|
||||||
match = pattern.search(content)
|
match = pattern.search(content)
|
||||||
if match:
|
if match:
|
||||||
# Replace the value after "graph:\n id: "
|
return pattern.sub(rf"\\1{workflow_id}", content, count=1)
|
||||||
return pattern.sub(rf"\1{workflow_id}", content, count=1)
|
|
||||||
|
|
||||||
# If no graph.id found, look for standalone id: at root level (legacy support)
|
|
||||||
root_id_pattern = re.compile(r"^(id:\s*).*$", re.MULTILINE)
|
|
||||||
root_match = root_id_pattern.search(content)
|
|
||||||
if root_match:
|
|
||||||
return root_id_pattern.sub(rf"\1{workflow_id}", content, count=1)
|
|
||||||
|
|
||||||
# If neither found, add graph.id after graph: section if it exists
|
|
||||||
graph_pattern = re.compile(r"(graph:\s*\n)")
|
|
||||||
graph_match = graph_pattern.search(content)
|
|
||||||
if graph_match:
|
|
||||||
return graph_pattern.sub(rf"\1 id: {workflow_id}\n", content, count=1)
|
|
||||||
|
|
||||||
# Fallback (is invalid)
|
|
||||||
lines = content.splitlines()
|
lines = content.splitlines()
|
||||||
insert_index = 0
|
insert_index = 0
|
||||||
if lines and lines[0].strip() == "---":
|
if lines and lines[0].strip() == "---":
|
||||||
insert_index = 1
|
insert_index = 1
|
||||||
lines.insert(insert_index, f"graph:\n id: {workflow_id}")
|
lines.insert(insert_index, f"id: {workflow_id}")
|
||||||
updated = "\n".join(lines)
|
updated = "\n".join(lines)
|
||||||
if content.endswith("\n"):
|
if content.endswith("\n"):
|
||||||
updated += "\n"
|
updated += "\n"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user