代码提交

This commit is contained in:
董宏凯 committed 2026-06-08 22:52:18 +08:00
1 parent b101ee7fc9
commit 1938c80061
42 files changed
+5552 -278

No files matched your search

+2 -1
View File
@@ -4,7 +4,8 @@
"PowerShell(uv --version)",
"PowerShell(netstat -ano)",
"PowerShell(Get-NetTCPConnection -LocalPort 8001 -ErrorAction SilentlyContinue)",
"PowerShell(Get-Process -Id 20560 -ErrorAction SilentlyContinue | Select-Object Id,ProcessName,Path,StartTime; try { \\(Invoke-WebRequest -Uri \"http://127.0.0.1:8001/health\" -UseBasicParsing -TimeoutSec 5\\).Content } catch { \"health check failed: $_\" })"
"PowerShell(Get-Process -Id 20560 -ErrorAction SilentlyContinue | Select-Object Id,ProcessName,Path,StartTime; try { \\(Invoke-WebRequest -Uri \"http://127.0.0.1:8001/health\" -UseBasicParsing -TimeoutSec 5\\).Content } catch { \"health check failed: $_\" })",
"Bash(curl -s -X POST -H \"Content-Type: application/json\" -d '{\"username\":\"demo\"}' http://localhost:8001/api/v1/auth/login/username)"
]
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ SERPER_API_KEY=your-serper-api-key
# AUTH_JWT_SECRET must stay stable across restarts, or existing logins expire.
# GATEWAY_CORS_ORIGINS must include the exact frontend origins, separated by commas.
AUTH_JWT_SECRET=i3_BiBK44sYHiFIkChhDs46y57ZewJKO3diQDw01anI
GATEWAY_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:5174,http://127.0.0.1:5174,http://localhost:4200
GATEWAY_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:5183,http://localhost:5174,http://127.0.0.1:5174,http://localhost:4200
GATEWAY_CORS_ALLOW_ALL=0
GATEWAY_ENABLE_DOCS=true
GATEWAY_WORKERS=2
+760
View File
@@ -0,0 +1,760 @@
# This file was autogenerated by uv via the following command:
# uv export --no-hashes --no-emit-project --no-dev -o _reqs.txt
-e ./packages/harness
# via deer-flow
agent-client-protocol==0.10.1
# via deerflow-harness
agent-sandbox==0.0.30
# via deerflow-harness
aiohappyeyeballs==2.6.2
# via aiohttp
aiohttp==3.14.1
# via
# aiohttp-retry
# dingtalk-stream
# firecrawl-py
# hindsight-client
# kubernetes
# wecom-aibot-python-sdk
aiohttp-retry==2.9.1
# via hindsight-client
aiosignal==1.4.0
# via aiohttp
aiosqlite==0.22.1
# via
# deerflow-harness
# langgraph-checkpoint-sqlite
alembic==1.18.4
# via deerflow-harness
annotated-doc==0.0.4
# via fastapi
annotated-types==0.7.0
# via pydantic
anthropic==0.107.1
# via langchain-anthropic
anyio==4.13.0
# via
# anthropic
# google-genai
# httpx
# mcp
# openai
# sse-starlette
# starlette
# watchfiles
asyncmy==0.2.11
# via deer-flow
attrs==26.1.0
# via
# aiohttp
# jsonschema
# referencing
audioop-lts==0.2.2 ; python_full_version >= '3.13'
# via
# speechrecognition
# standard-aifc
azure-ai-documentintelligence==1.0.2
# via markitdown
azure-core==1.41.0
# via
# azure-ai-documentintelligence
# azure-identity
azure-identity==1.25.3
# via markitdown
backoff==2.2.1
# via langfuse
bcrypt==5.0.0
# via deer-flow
beautifulsoup4==4.15.0
# via
# markdownify
# markitdown
# readabilipy
blockbuster==1.5.26
# via langgraph-runtime-inmem
brotli==1.2.0 ; platform_python_implementation == 'CPython'
# via httpx
brotlicffi==1.2.0.1 ; platform_python_implementation != 'CPython'
# via httpx
certifi==2026.5.20
# via
# httpcore
# httpx
# kubernetes
# requests
# volcengine-python-sdk
# wecom-aibot-python-sdk
cffi==2.0.0
# via
# brotlicffi
# cryptography
charset-normalizer==3.4.7
# via
# markitdown
# pdfminer-six
# requests
click==8.4.1
# via
# ddgs
# langgraph-cli
# magika
# uvicorn
cloudpickle==3.1.2
# via langgraph-api
cobble==0.1.4
# via mammoth
colorama==0.4.6 ; sys_platform == 'win32'
# via
# click
# tqdm
# uvicorn
coloredlogs==15.0.1
# via onnxruntime
croniter==6.2.2
# via
# deer-flow
# langgraph-runtime-inmem
cryptography==48.0.0
# via
# azure-identity
# google-auth
# langgraph-api
# msal
# pdfminer-six
# pyjwt
# wecom-aibot-python-sdk
ddgs==9.14.4
# via deerflow-harness
defusedxml==0.7.1
# via
# markitdown
# youtube-transcript-api
dingtalk-stream==0.24.3
# via deer-flow
distro==1.9.0
# via
# anthropic
# google-genai
# openai
dnspython==2.8.0
# via email-validator
docstring-parser==0.18.0
# via anthropic
dotenv==0.9.9
# via deerflow-harness
duckdb==1.5.3
# via deerflow-harness
durationpy==0.10
# via kubernetes
email-validator==2.3.0
# via deer-flow
et-xmlfile==2.0.0
# via openpyxl
exa-py==2.13.0
# via deerflow-harness
fake-useragent==2.2.0
# via ddgs
fastapi==0.136.3
# via deer-flow
filetype==1.2.0
# via langchain-google-genai
firecrawl-py==4.28.2
# via deerflow-harness
flatbuffers==25.12.19
# via onnxruntime
forbiddenfruit==0.1.4 ; implementation_name == 'cpython'
# via blockbuster
frozenlist==1.8.0
# via
# aiohttp
# aiosignal
google-auth==2.53.0
# via google-genai
google-genai==2.8.0
# via langchain-google-genai
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
greenlet==3.5.1
# via sqlalchemy
grpcio==1.80.0
# via
# grpcio-health-checking
# grpcio-tools
# langgraph-api
grpcio-health-checking==1.80.0
# via langgraph-api
grpcio-tools==1.80.0
# via langgraph-api
h11==0.16.0
# via
# httpcore
# uvicorn
h2==4.3.0
# via httpx
hindsight-client==0.7.2
# via deer-flow
hpack==4.1.0
# via h2
html5lib==1.1
# via readabilipy
httpcore==1.0.9
# via
# exa-py
# httpx
# python-telegram-bot
httptools==0.8.0
# via
# langgraph-api
# uvicorn
httpx==0.28.1
# via
# agent-sandbox
# anthropic
# ddgs
# deer-flow
# deerflow-harness
# exa-py
# firecrawl-py
# google-genai
# langfuse
# langgraph-api
# langgraph-cli
# langgraph-sdk
# langsmith
# lark-oapi
# mcp
# openai
# python-telegram-bot
# tavily-python
httpx-sse==0.4.3
# via mcp
humanfriendly==10.0
# via coloredlogs
hyperframe==6.1.0
# via h2
idna==3.18
# via
# anyio
# email-validator
# httpx
# requests
# yarl
isodate==0.7.2
# via azure-ai-documentintelligence
jiter==0.15.0
# via
# anthropic
# openai
jsonpatch==1.33
# via langchain-core
jsonpointer==3.1.1
# via jsonpatch
jsonschema==4.26.0
# via mcp
jsonschema-rs==0.44.1
# via langgraph-api
jsonschema-specifications==2025.9.1
# via jsonschema
kubernetes==36.0.2
# via deerflow-harness
langchain==1.3.4
# via deerflow-harness
langchain-anthropic==1.4.4
# via deerflow-harness
langchain-core==1.4.1
# via
# langchain
# langchain-anthropic
# langchain-deepseek
# langchain-google-genai
# langchain-mcp-adapters
# langchain-openai
# langgraph
# langgraph-api
# langgraph-checkpoint
# langgraph-prebuilt
# langgraph-sdk
langchain-deepseek==1.1.0
# via deerflow-harness
langchain-google-genai==4.2.4
# via deerflow-harness
langchain-mcp-adapters==0.2.2
# via deerflow-harness
langchain-openai==1.2.2
# via
# deerflow-harness
# langchain-deepseek
langchain-protocol==0.0.16
# via
# langchain-core
# langgraph-api
# langgraph-sdk
langfuse==4.7.1
# via deerflow-harness
langgraph==1.2.4
# via
# deerflow-harness
# langchain
# langgraph-api
# langgraph-runtime-inmem
langgraph-api==0.9.0
# via deerflow-harness
langgraph-checkpoint==4.1.1
# via
# langgraph
# langgraph-api
# langgraph-checkpoint-postgres
# langgraph-checkpoint-sqlite
# langgraph-prebuilt
# langgraph-runtime-inmem
langgraph-checkpoint-postgres==3.1.0
# via deerflow-harness
langgraph-checkpoint-sqlite==3.1.0
# via deerflow-harness
langgraph-cli==0.4.27
# via deerflow-harness
langgraph-prebuilt==1.1.0
# via langgraph
langgraph-runtime-inmem==0.29.0
# via
# deerflow-harness
# langgraph-api
langgraph-sdk==0.4.2
# via
# deer-flow
# deerflow-harness
# langgraph
# langgraph-api
# langgraph-cli
langsmith==0.8.9
# via
# langchain-core
# langgraph-api
lark-oapi==1.6.8
# via deer-flow
lxml==6.1.1
# via
# ddgs
# markitdown
# python-pptx
# readabilipy
magika==0.6.3
# via markitdown
mako==1.3.12
# via alembic
mammoth==1.11.0
# via markitdown
markdown-to-mrkdwn==0.3.3
# via deer-flow
markdownify==1.2.2
# via
# deerflow-harness
# markitdown
markitdown==0.1.5
# via deerflow-harness
markupsafe==3.0.3
# via mako
mcp==1.27.2
# via langchain-mcp-adapters
mpmath==1.3.0
# via sympy
msal==1.37.0
# via
# azure-identity
# msal-extensions
msal-extensions==1.3.1
# via azure-identity
multidict==6.7.1
# via
# aiohttp
# yarl
nest-asyncio==1.6.0
# via firecrawl-py
numpy==2.4.6
# via
# magika
# onnxruntime
# pandas
oauthlib==3.3.1
# via requests-oauthlib
olefile==0.47
# via markitdown
onnxruntime==1.20.1
# via magika
openai==2.41.0
# via
# exa-py
# langchain-openai
openpyxl==3.1.5
# via markitdown
opentelemetry-api==1.42.1
# via
# langfuse
# langgraph-api
# langsmith
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-sdk
# opentelemetry-semantic-conventions
opentelemetry-exporter-otlp-proto-common==1.42.1
# via opentelemetry-exporter-otlp-proto-http
opentelemetry-exporter-otlp-proto-http==1.42.1
# via
# langfuse
# langgraph-api
# langsmith
opentelemetry-proto==1.42.1
# via
# opentelemetry-exporter-otlp-proto-common
# opentelemetry-exporter-otlp-proto-http
opentelemetry-sdk==1.42.1
# via
# langfuse
# langgraph-api
# langsmith
# opentelemetry-exporter-otlp-proto-http
opentelemetry-semantic-conventions==0.63b1
# via opentelemetry-sdk
orjson==3.11.9
# via
# langgraph-api
# langgraph-checkpoint-postgres
# langgraph-sdk
# langsmith
ormsgpack==1.12.2
# via langgraph-checkpoint
packaging==26.2
# via
# langchain-core
# langfuse
# langsmith
# onnxruntime
pandas==3.0.3
# via markitdown
pathspec==1.1.1
# via langgraph-cli
pdfminer-six==20251230
# via
# markitdown
# pdfplumber
pdfplumber==0.11.9
# via markitdown
pillow==12.2.0
# via
# pdfplumber
# python-pptx
primp==1.3.1
# via ddgs
propcache==0.5.2
# via
# aiohttp
# yarl
protobuf==6.33.6
# via
# googleapis-common-protos
# grpcio-health-checking
# grpcio-tools
# langgraph-api
# onnxruntime
# opentelemetry-proto
psycopg==3.3.4
# via
# deerflow-harness
# langgraph-checkpoint-postgres
psycopg-binary==3.3.4 ; implementation_name != 'pypy'
# via psycopg
psycopg-pool==3.3.1
# via
# deerflow-harness
# langgraph-checkpoint-postgres
pyasn1==0.6.3
# via pyasn1-modules
pyasn1-modules==0.4.2
# via google-auth
pycparser==3.0 ; implementation_name != 'PyPy'
# via cffi
pycryptodome==3.23.0
# via lark-oapi
pydantic==2.13.4
# via
# agent-client-protocol
# agent-sandbox
# anthropic
# deerflow-harness
# exa-py
# fastapi
# firecrawl-py
# google-genai
# hindsight-client
# langchain
# langchain-anthropic
# langchain-core
# langchain-google-genai
# langfuse
# langgraph
# langsmith
# mcp
# openai
# pydantic-settings
pydantic-core==2.46.4
# via pydantic
pydantic-settings==2.14.1
# via mcp
pydub==0.25.1
# via markitdown
pyee==13.0.1
# via wecom-aibot-python-sdk
pyjwt==2.13.0
# via
# deer-flow
# langgraph-api
# mcp
# msal
pypdfium2==5.9.0
# via pdfplumber
pyreadline3==3.5.6 ; sys_platform == 'win32'
# via humanfriendly
python-dateutil==2.9.0.post0
# via
# croniter
# hindsight-client
# kubernetes
# pandas
# volcengine-python-sdk
python-dotenv==1.2.2
# via
# dotenv
# exa-py
# firecrawl-py
# langgraph-cli
# magika
# pydantic-settings
# uvicorn
python-multipart==0.0.32
# via
# deer-flow
# mcp
python-pptx==1.0.2
# via markitdown
python-telegram-bot==22.7
# via deer-flow
pywin32==312 ; sys_platform == 'win32'
# via mcp
pyyaml==6.0.3
# via
# deerflow-harness
# kubernetes
# langchain-core
# uvicorn
readabilipy==0.3.0
# via deerflow-harness
referencing==0.37.0
# via
# jsonschema
# jsonschema-specifications
regex==2026.5.9
# via
# readabilipy
# tiktoken
requests==2.34.2
# via
# azure-core
# dingtalk-stream
# exa-py
# firecrawl-py
# google-auth
# google-genai
# kubernetes
# langsmith
# lark-oapi
# markitdown
# msal
# opentelemetry-exporter-otlp-proto-http
# requests-oauthlib
# requests-toolbelt
# tavily-python
# tiktoken
# youtube-transcript-api
requests-oauthlib==2.0.0
# via kubernetes
requests-toolbelt==1.0.0
# via
# langsmith
# lark-oapi
rpds-py==2026.5.1
# via
# jsonschema
# referencing
setuptools==82.0.1
# via grpcio-tools
six==1.17.0
# via
# html5lib
# kubernetes
# markdownify
# python-dateutil
# volcengine-python-sdk
slack-sdk==3.42.0
# via deer-flow
sniffio==1.3.1
# via
# anthropic
# google-genai
# openai
socksio==1.0.0
# via httpx
soupsieve==2.8.4
# via beautifulsoup4
speechrecognition==3.16.1
# via markitdown
sqlalchemy==2.0.50
# via
# alembic
# deerflow-harness
sqlite-vec==0.1.9
# via langgraph-checkpoint-sqlite
sse-starlette==3.3.4
# via
# deer-flow
# langgraph-api
# langgraph-runtime-inmem
# mcp
standard-aifc==3.13.0 ; python_full_version >= '3.13'
# via speechrecognition
standard-chunk==3.13.0 ; python_full_version >= '3.13'
# via standard-aifc
starlette==1.2.1
# via
# fastapi
# langgraph-api
# langgraph-runtime-inmem
# mcp
# sse-starlette
structlog==25.5.0
# via
# langgraph-api
# langgraph-runtime-inmem
sympy==1.14.0
# via onnxruntime
tavily-python==0.7.25
# via deerflow-harness
tenacity==9.1.4
# via
# google-genai
# langchain-core
# langgraph-api
tiktoken==0.13.0
# via
# deerflow-harness
# langchain-openai
# tavily-python
tqdm==4.68.1
# via openai
truststore==0.10.4
# via langgraph-api
typing-extensions==4.15.0
# via
# aiohttp
# aiosignal
# alembic
# anthropic
# anyio
# azure-ai-documentintelligence
# azure-core
# azure-identity
# beautifulsoup4
# exa-py
# fastapi
# google-genai
# grpcio
# hindsight-client
# langchain-core
# langchain-mcp-adapters
# langchain-protocol
# mcp
# openai
# opentelemetry-api
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-sdk
# opentelemetry-semantic-conventions
# psycopg
# psycopg-pool
# pydantic
# pydantic-core
# pyee
# python-pptx
# referencing
# speechrecognition
# sqlalchemy
# starlette
# typing-inspection
typing-inspection==0.4.2
# via
# fastapi
# mcp
# pydantic
# pydantic-settings
tzdata==2026.2 ; sys_platform == 'emscripten' or sys_platform == 'win32'
# via
# pandas
# psycopg
urllib3==2.7.0
# via
# hindsight-client
# kubernetes
# requests
# volcengine-python-sdk
uuid-utils==0.16.0
# via
# langchain-core
# langgraph-api
# langsmith
uvicorn==0.49.0
# via
# deer-flow
# langgraph-api
# mcp
uvloop==0.22.1 ; sys_platform != 'win32'
# via
# langgraph-api
# uvicorn
volcengine-python-sdk==5.0.33
# via agent-sandbox
watchfiles==1.2.0
# via
# langgraph-api
# uvicorn
webencodings==0.5.1
# via html5lib
websocket-client==1.9.0
# via kubernetes
websockets==15.0.1
# via
# dingtalk-stream
# firecrawl-py
# google-genai
# langgraph-api
# langgraph-sdk
# langsmith
# lark-oapi
# uvicorn
# wecom-aibot-python-sdk
wecom-aibot-python-sdk==1.0.2
# via deer-flow
wrapt==1.17.3
# via langfuse
xlrd==2.0.2
# via markitdown
xlsxwriter==3.2.9
# via python-pptx
xxhash==3.7.0
# via
# langgraph
# langsmith
yarl==1.24.2
# via aiohttp
youtube-transcript-api==1.0.3
# via markitdown
zstandard==0.25.0
# via
# langgraph-api
# langsmith
@@ -30,9 +30,9 @@ from app.gateway.routers import (
artifacts,
assistants_compat,
auth,
channels,
checkpoint_migration,
feedback,
channels,
checkpoint_migration,
feedback,
html_page_favorites,
intent,
llm_metrics,
@@ -41,6 +41,8 @@ from app.gateway.routers import (
models,
multi_agent,
notifications,
parallel_agent_tasks,
parallel_agents,
public_embed,
public_scheduled_tasks,
recommend,
@@ -378,7 +380,7 @@ def _release_singleton_lock(handle) -> None:
pass
async def _curator_scheduler_loop() -> None:
async def _curator_scheduler_loop() -> None:
"""常驻后台任务:每隔 _CURATOR_CHECK_INTERVAL_SECONDS 检查一次需不需要进化。
多 worker 部署下用文件锁做 leader 选举 —— 只有抢到锁的那个 worker 进程真正
@@ -404,248 +406,248 @@ async def _curator_scheduler_loop() -> None:
except Exception:
logger.exception("Curator scheduler tick failed (non-fatal)")
await asyncio.sleep(_CURATOR_CHECK_INTERVAL_SECONDS)
finally:
_release_singleton_lock(lock_handle)
def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() not in {"0", "false", "no", "off"}
def _env_int(name: str, default: int, *, alias: str | None = None, minimum: int = 1) -> int:
raw = os.getenv(name)
if raw is None and alias:
raw = os.getenv(alias)
if raw is None:
raw = str(default)
try:
value = int(raw)
except ValueError:
return default
return max(minimum, value)
def _env_float(name: str, default: float, *, alias: str | None = None, minimum: float = 0.0) -> float:
raw = os.getenv(name)
if raw is None and alias:
raw = os.getenv(alias)
if raw is None:
raw = str(default)
try:
value = float(raw)
except ValueError:
return default
return max(minimum, value)
_LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED = _env_bool("ADMIN_LEADERBOARD_BACKGROUND_ENABLED", True)
_LEADERBOARD_SNAPSHOT_PREWARM_DAYS = _env_int(
"ADMIN_LEADERBOARD_BACKGROUND_PREWARM_DAYS",
30,
)
_LEADERBOARD_SNAPSHOT_BATCH_SIZE = _env_int(
"ADMIN_LEADERBOARD_BACKGROUND_BATCH_SIZE",
1,
)
_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS = _env_float(
"ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS",
300.0,
)
_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS = _env_float(
"ADMIN_LEADERBOARD_BACKGROUND_SLEEP_SECONDS",
2.0,
)
_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS = _env_int(
"ADMIN_LEADERBOARD_BACKGROUND_RUNNING_TTL_SECONDS",
1800,
)
_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS = _env_float(
"ADMIN_LEADERBOARD_BACKGROUND_STARTUP_DELAY_SECONDS",
60.0,
)
def _recent_leaderboard_stat_dates(days: int) -> list[str]:
from datetime import datetime, timedelta
from deerflow.persistence.types import BEIJING_TZ
today = datetime.now(BEIJING_TZ).date()
start = today - timedelta(days=max(1, days) - 1)
return [(start + timedelta(days=i)).isoformat() for i in range((today - start).days + 1)]
async def _leaderboard_snapshot_due_dates(
session_factory,
*,
settings_hash: str,
limit: int,
running_ttl_seconds: int,
) -> list[str]:
from datetime import UTC, datetime, timedelta
from sqlalchemy import and_, or_, select
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
cutoff = datetime.now(UTC) - timedelta(seconds=running_ttl_seconds)
async with session_factory() as session:
rows = (
await session.execute(
select(AdminLeaderboardDailyStatRow.stat_date)
.where(
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
or_(
AdminLeaderboardDailyStatRow.status.in_(["queued", "error"]),
and_(
AdminLeaderboardDailyStatRow.status == "running",
AdminLeaderboardDailyStatRow.updated_at < cutoff,
),
),
)
.order_by(AdminLeaderboardDailyStatRow.stat_date.desc())
.limit(limit)
)
).all()
return [str(row[0]) for row in rows]
async def _claim_leaderboard_snapshot(
session_factory,
*,
stat_date: str,
settings_hash: str,
running_ttl_seconds: int,
) -> bool:
from datetime import UTC, datetime
from app.gateway.routers.admin_users import _snapshot_is_fresh
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
from deerflow.persistence.types import BEIJING_TZ
async with session_factory() as session:
row = await session.get(
AdminLeaderboardDailyStatRow,
{"stat_date": stat_date, "settings_hash": settings_hash},
)
if row is None or _snapshot_is_fresh(row, stat_date):
return False
if row.status == "running":
updated_at = row.updated_at
if isinstance(updated_at, datetime):
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=BEIJING_TZ)
age = (datetime.now(BEIJING_TZ) - updated_at.astimezone(BEIJING_TZ)).total_seconds()
if age < running_ttl_seconds:
return False
row.status = "running"
row.message = "排行榜后台统计任务已认领。"
row.error = None
row.updated_at = datetime.now(UTC)
await session.commit()
return True
async def _run_leaderboard_snapshot_tick() -> tuple[int, int]:
from app.gateway.routers.admin_users import (
_daily_job_key,
_queue_daily_leaderboard_snapshots,
_refresh_daily_leaderboard_snapshot,
_resolve_leaderboard_snapshot_scope,
)
from deerflow.config.system_settings import load_system_settings
from deerflow.persistence.engine import get_session_factory
session_factory = get_session_factory()
if session_factory is None:
return 0, 0
settings = load_system_settings().leaderboard
excluded, include_scheduled, include_admins, include_failed, settings_hash = _resolve_leaderboard_snapshot_scope(settings)
stat_dates = _recent_leaderboard_stat_dates(_LEADERBOARD_SNAPSHOT_PREWARM_DAYS)
queued_dates = await _queue_daily_leaderboard_snapshots(
session_factory,
stat_dates,
settings_hash,
message="排行榜后台统计任务定期预热/补算队列。",
)
due_dates = await _leaderboard_snapshot_due_dates(
session_factory,
settings_hash=settings_hash,
limit=_LEADERBOARD_SNAPSHOT_BATCH_SIZE,
running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
)
processed = 0
for stat_date in due_dates:
claimed = await _claim_leaderboard_snapshot(
session_factory,
stat_date=stat_date,
settings_hash=settings_hash,
running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
)
if not claimed:
continue
logger.info("Leaderboard snapshot scheduler: building date=%s hash=%s", stat_date, settings_hash[:10])
await _refresh_daily_leaderboard_snapshot(
_daily_job_key(stat_date, settings_hash),
session_factory=session_factory,
settings=settings,
stat_date=stat_date,
settings_hash=settings_hash,
excluded=excluded,
include_scheduled=include_scheduled,
include_admins=include_admins,
include_failed=include_failed,
)
processed += 1
if _LEADERBOARD_SNAPSHOT_SLEEP_SECONDS > 0 and processed < len(due_dates):
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS)
return processed, len(queued_dates)
async def _leaderboard_snapshot_scheduler_loop() -> None:
"""Background task inside the existing gateway process for daily snapshots."""
if not _LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED:
logger.info("Leaderboard snapshot scheduler disabled by ADMIN_LEADERBOARD_BACKGROUND_ENABLED")
return
from deerflow.config.runtime_paths import runtime_home
lock_handle = _acquire_singleton_lock(runtime_home() / ".leaderboard_snapshot_scheduler.lock")
if lock_handle is None:
logger.info("Leaderboard snapshot scheduler: another gateway worker holds the lock; this worker skips")
return
logger.info(
"Leaderboard snapshot scheduler started: prewarm_days=%d batch_size=%d interval=%.1fs",
_LEADERBOARD_SNAPSHOT_PREWARM_DAYS,
_LEADERBOARD_SNAPSHOT_BATCH_SIZE,
_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS,
)
try:
if _LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS > 0:
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS)
while True:
try:
processed, queued = await _run_leaderboard_snapshot_tick()
if processed or queued:
logger.info(
"Leaderboard snapshot scheduler tick: queued=%d processed=%d",
queued,
processed,
)
except Exception:
logger.exception("Leaderboard snapshot scheduler tick failed (non-fatal)")
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS)
finally:
_release_singleton_lock(lock_handle)
def _warn_if_multi_worker() -> None:
finally:
_release_singleton_lock(lock_handle)
def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() not in {"0", "false", "no", "off"}
def _env_int(name: str, default: int, *, alias: str | None = None, minimum: int = 1) -> int:
raw = os.getenv(name)
if raw is None and alias:
raw = os.getenv(alias)
if raw is None:
raw = str(default)
try:
value = int(raw)
except ValueError:
return default
return max(minimum, value)
def _env_float(name: str, default: float, *, alias: str | None = None, minimum: float = 0.0) -> float:
raw = os.getenv(name)
if raw is None and alias:
raw = os.getenv(alias)
if raw is None:
raw = str(default)
try:
value = float(raw)
except ValueError:
return default
return max(minimum, value)
_LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED = _env_bool("ADMIN_LEADERBOARD_BACKGROUND_ENABLED", True)
_LEADERBOARD_SNAPSHOT_PREWARM_DAYS = _env_int(
"ADMIN_LEADERBOARD_BACKGROUND_PREWARM_DAYS",
30,
)
_LEADERBOARD_SNAPSHOT_BATCH_SIZE = _env_int(
"ADMIN_LEADERBOARD_BACKGROUND_BATCH_SIZE",
1,
)
_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS = _env_float(
"ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS",
300.0,
)
_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS = _env_float(
"ADMIN_LEADERBOARD_BACKGROUND_SLEEP_SECONDS",
2.0,
)
_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS = _env_int(
"ADMIN_LEADERBOARD_BACKGROUND_RUNNING_TTL_SECONDS",
1800,
)
_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS = _env_float(
"ADMIN_LEADERBOARD_BACKGROUND_STARTUP_DELAY_SECONDS",
60.0,
)
def _recent_leaderboard_stat_dates(days: int) -> list[str]:
from datetime import datetime, timedelta
from deerflow.persistence.types import BEIJING_TZ
today = datetime.now(BEIJING_TZ).date()
start = today - timedelta(days=max(1, days) - 1)
return [(start + timedelta(days=i)).isoformat() for i in range((today - start).days + 1)]
async def _leaderboard_snapshot_due_dates(
session_factory,
*,
settings_hash: str,
limit: int,
running_ttl_seconds: int,
) -> list[str]:
from datetime import UTC, datetime, timedelta
from sqlalchemy import and_, or_, select
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
cutoff = datetime.now(UTC) - timedelta(seconds=running_ttl_seconds)
async with session_factory() as session:
rows = (
await session.execute(
select(AdminLeaderboardDailyStatRow.stat_date)
.where(
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
or_(
AdminLeaderboardDailyStatRow.status.in_(["queued", "error"]),
and_(
AdminLeaderboardDailyStatRow.status == "running",
AdminLeaderboardDailyStatRow.updated_at < cutoff,
),
),
)
.order_by(AdminLeaderboardDailyStatRow.stat_date.desc())
.limit(limit)
)
).all()
return [str(row[0]) for row in rows]
async def _claim_leaderboard_snapshot(
session_factory,
*,
stat_date: str,
settings_hash: str,
running_ttl_seconds: int,
) -> bool:
from datetime import UTC, datetime
from app.gateway.routers.admin_users import _snapshot_is_fresh
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
from deerflow.persistence.types import BEIJING_TZ
async with session_factory() as session:
row = await session.get(
AdminLeaderboardDailyStatRow,
{"stat_date": stat_date, "settings_hash": settings_hash},
)
if row is None or _snapshot_is_fresh(row, stat_date):
return False
if row.status == "running":
updated_at = row.updated_at
if isinstance(updated_at, datetime):
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=BEIJING_TZ)
age = (datetime.now(BEIJING_TZ) - updated_at.astimezone(BEIJING_TZ)).total_seconds()
if age < running_ttl_seconds:
return False
row.status = "running"
row.message = "排行榜后台统计任务已认领。"
row.error = None
row.updated_at = datetime.now(UTC)
await session.commit()
return True
async def _run_leaderboard_snapshot_tick() -> tuple[int, int]:
from app.gateway.routers.admin_users import (
_daily_job_key,
_queue_daily_leaderboard_snapshots,
_refresh_daily_leaderboard_snapshot,
_resolve_leaderboard_snapshot_scope,
)
from deerflow.config.system_settings import load_system_settings
from deerflow.persistence.engine import get_session_factory
session_factory = get_session_factory()
if session_factory is None:
return 0, 0
settings = load_system_settings().leaderboard
excluded, include_scheduled, include_admins, include_failed, settings_hash = _resolve_leaderboard_snapshot_scope(settings)
stat_dates = _recent_leaderboard_stat_dates(_LEADERBOARD_SNAPSHOT_PREWARM_DAYS)
queued_dates = await _queue_daily_leaderboard_snapshots(
session_factory,
stat_dates,
settings_hash,
message="排行榜后台统计任务定期预热/补算队列。",
)
due_dates = await _leaderboard_snapshot_due_dates(
session_factory,
settings_hash=settings_hash,
limit=_LEADERBOARD_SNAPSHOT_BATCH_SIZE,
running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
)
processed = 0
for stat_date in due_dates:
claimed = await _claim_leaderboard_snapshot(
session_factory,
stat_date=stat_date,
settings_hash=settings_hash,
running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
)
if not claimed:
continue
logger.info("Leaderboard snapshot scheduler: building date=%s hash=%s", stat_date, settings_hash[:10])
await _refresh_daily_leaderboard_snapshot(
_daily_job_key(stat_date, settings_hash),
session_factory=session_factory,
settings=settings,
stat_date=stat_date,
settings_hash=settings_hash,
excluded=excluded,
include_scheduled=include_scheduled,
include_admins=include_admins,
include_failed=include_failed,
)
processed += 1
if _LEADERBOARD_SNAPSHOT_SLEEP_SECONDS > 0 and processed < len(due_dates):
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS)
return processed, len(queued_dates)
async def _leaderboard_snapshot_scheduler_loop() -> None:
"""Background task inside the existing gateway process for daily snapshots."""
if not _LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED:
logger.info("Leaderboard snapshot scheduler disabled by ADMIN_LEADERBOARD_BACKGROUND_ENABLED")
return
from deerflow.config.runtime_paths import runtime_home
lock_handle = _acquire_singleton_lock(runtime_home() / ".leaderboard_snapshot_scheduler.lock")
if lock_handle is None:
logger.info("Leaderboard snapshot scheduler: another gateway worker holds the lock; this worker skips")
return
logger.info(
"Leaderboard snapshot scheduler started: prewarm_days=%d batch_size=%d interval=%.1fs",
_LEADERBOARD_SNAPSHOT_PREWARM_DAYS,
_LEADERBOARD_SNAPSHOT_BATCH_SIZE,
_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS,
)
try:
if _LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS > 0:
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS)
while True:
try:
processed, queued = await _run_leaderboard_snapshot_tick()
if processed or queued:
logger.info(
"Leaderboard snapshot scheduler tick: queued=%d processed=%d",
queued,
processed,
)
except Exception:
logger.exception("Leaderboard snapshot scheduler tick failed (non-fatal)")
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS)
finally:
_release_singleton_lock(lock_handle)
def _warn_if_multi_worker() -> None:
"""检测 uvicorn / gunicorn 多 worker 部署并打 ERROR 日志。
主 agent / canvas / AI 写作流式响应在 Gateway 进程内通过 ``StreamBridge``
@@ -799,21 +801,21 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# 默认每天凌晨 3 点扫一次,删 retention_days(默认 7 天)以前的会话,联动
# 清理 MySQL 业务表 + SQLite checkpointer 里的 thread 状态。多 worker
# 下用文件锁选 leader,只有抢到锁的 worker 真正跑清理。
try:
from app.gateway.ai_writing_cleanup import cleanup_scheduler_loop
app.state.ai_writing_cleanup_task = asyncio.create_task(cleanup_scheduler_loop(app))
except Exception:
logger.exception("AI writing cleanup scheduler failed to start (non-fatal)")
try:
app.state.leaderboard_snapshot_scheduler_task = asyncio.create_task(_leaderboard_snapshot_scheduler_loop())
except Exception:
logger.exception("Leaderboard snapshot scheduler failed to start (non-fatal)")
# Start IM channel service if any channels are configured
try:
from app.channels.service import start_channel_service
try:
from app.gateway.ai_writing_cleanup import cleanup_scheduler_loop
app.state.ai_writing_cleanup_task = asyncio.create_task(cleanup_scheduler_loop(app))
except Exception:
logger.exception("AI writing cleanup scheduler failed to start (non-fatal)")
try:
app.state.leaderboard_snapshot_scheduler_task = asyncio.create_task(_leaderboard_snapshot_scheduler_loop())
except Exception:
logger.exception("Leaderboard snapshot scheduler failed to start (non-fatal)")
# Start IM channel service if any channels are configured
try:
from app.channels.service import start_channel_service
channel_service = await start_channel_service(app.state.config)
logger.info("Channel service started: %s", channel_service.get_status())
@@ -857,17 +859,17 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except asyncio.CancelledError:
pass
except Exception:
logger.exception("AI writing cleanup scheduler shutdown error")
leaderboard_snapshot_task = getattr(app.state, "leaderboard_snapshot_scheduler_task", None)
if leaderboard_snapshot_task is not None:
leaderboard_snapshot_task.cancel()
try:
await leaderboard_snapshot_task
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Leaderboard snapshot scheduler shutdown error")
logger.exception("AI writing cleanup scheduler shutdown error")
leaderboard_snapshot_task = getattr(app.state, "leaderboard_snapshot_scheduler_task", None)
if leaderboard_snapshot_task is not None:
leaderboard_snapshot_task.cancel()
try:
await leaderboard_snapshot_task
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Leaderboard snapshot scheduler shutdown error")
# 阶段 7 删冗余:AI 写作 graph 不再自管 checkpointer / in-flight task;
# thread 状态由 LangGraph Server 主 checkpointer 接管,lifespan 出口由
@@ -1122,6 +1124,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Roundtable-planning business chains at /api/roundtable-chains
app.include_router(roundtable_chains.router)
# Standalone parallel multi-agent panel: fan-out streaming + saved tasks
app.include_router(parallel_agents.router)
app.include_router(parallel_agent_tasks.router)
# Roundtable-planning background jobs at /api/roundtable-jobs
app.include_router(roundtable_jobs.router)
@@ -1159,10 +1165,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
app.include_router(tool_metrics.router)
# Channels API is mounted at /api/channels
app.include_router(channels.router)
# Admin checkpoint migration API: SQLite legacy checkpoints -> Postgres.
app.include_router(checkpoint_migration.router)
app.include_router(channels.router)
# Admin checkpoint migration API: SQLite legacy checkpoints -> Postgres.
app.include_router(checkpoint_migration.router)
# Assistants compatibility API (LangGraph Platform stub)
app.include_router(assistants_compat.router)
@@ -179,6 +179,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
from deerflow.persistence.roundtable_jobs import (
make_roundtable_job_store,
register_default_store as _register_roundtable_job_default_store,
@@ -0,0 +1,148 @@
"""CRUD API for parallel multi-agent task records (并行多智能体任务).
A task = one saved run of the standalone parallel multi-agent panel: a task name
+ brief dispatched to several agents in parallel, plus the resulting message list.
Bound to the requesting user (not the browser), so it follows the account across
devices/sessions — same model as roundtable drafts.
Routes (prefix ``/api/parallel-agent-tasks``):
GET "" list current user's tasks (lightweight meta, no messages)
POST "" create a task
GET "/{task_id}" one task (full, incl. messages)
PUT "/{task_id}" partial update (title / brief / agents / messages)
DELETE "/{task_id}" delete
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/parallel-agent-tasks", tags=["parallel-agent-tasks"])
# ── schemas ────────────────────────────────────────────────────────────────
class TaskAgentSchema(BaseModel):
agent_id: str = Field(min_length=1, max_length=128)
name: str = ""
avatarType: str | None = None
class TaskResponse(BaseModel):
id: str
title: str = ""
brief: str | None = None
agents: list[TaskAgentSchema] = []
# messages is an opaque dialogue list (mirrors the frontend Dialogue[] shape);
# absent on list responses (lightweight), present on get.
messages: list[dict[str, Any]] | None = None
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class TaskListResponse(BaseModel):
tasks: list[TaskResponse]
class TaskCreateRequest(BaseModel):
id: str | None = Field(default=None, max_length=64)
title: str = Field(default="", max_length=512)
brief: str | None = None
agents: list[TaskAgentSchema] = []
messages: list[dict[str, Any]] = []
class TaskUpdateRequest(BaseModel):
# All optional → callers send only what changed.
title: str | None = Field(default=None, max_length=512)
brief: str | None = None
agents: list[TaskAgentSchema] | None = None
messages: list[dict[str, Any]] | None = None
# ── helpers ──────────────────────────────────────────────────────────────
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
def _get_store(request: Request):
store = getattr(request.app.state, "parallel_agent_task_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Parallel agent task store not available")
return store
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=TaskListResponse)
async def list_tasks(request: Request) -> TaskListResponse:
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_tasks(user_id)
return TaskListResponse(tasks=[TaskResponse(**r) for r in rows])
@router.post("", response_model=TaskResponse, status_code=201)
async def create_task(request: Request, body: TaskCreateRequest) -> TaskResponse:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.create_task(
user_id,
{
"id": (body.id or uuid4().hex),
"title": body.title.strip(),
"brief": body.brief,
"agents": [a.model_dump() for a in body.agents],
"messages": body.messages,
},
)
return TaskResponse(**row)
@router.get("/{task_id}", response_model=TaskResponse)
async def get_task(request: Request, task_id: str) -> TaskResponse:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.get_task(task_id, user_id)
if row is None:
raise HTTPException(status_code=404, detail="Task not found")
return TaskResponse(**row)
@router.put("/{task_id}", response_model=TaskResponse)
async def update_task(request: Request, task_id: str, body: TaskUpdateRequest) -> TaskResponse:
store = _get_store(request)
user_id = _current_user_id(request)
fields = body.model_dump(exclude_unset=True)
# Normalize agents (pydantic models) to plain dicts for JSON storage.
if "agents" in fields and fields["agents"] is not None:
fields["agents"] = [a if isinstance(a, dict) else a.model_dump() for a in body.agents or []]
row = await store.update_task(task_id, user_id, **fields)
if row is None:
raise HTTPException(status_code=404, detail="Task not found")
return TaskResponse(**row)
@router.delete("/{task_id}", status_code=204)
async def delete_task(request: Request, task_id: str) -> None:
store = _get_store(request)
user_id = _current_user_id(request)
deleted = await store.delete_task(task_id, user_id)
if not deleted:
raise HTTPException(status_code=404, detail="Task not found")
@@ -0,0 +1,247 @@
"""Server-side parallel fan-out for the standalone parallel multi-agent panel.
The Vue embeddable panel sends a single request — ``{task_name, brief, agent_ids}``
— and this endpoint **fans out** to every agent in parallel (file writes disabled),
then **merges** their LangGraph streams into ONE SSE tagged per ``agent_id``, so the
client only consumes one stream and never parses LangGraph internals.
It reuses the roundtable single-agent machinery from ``multi_agent``:
``_special_run(..., no_file=True)`` runs one seat under the ``seat_parallel`` run
policy (which excludes ``write_file`` / ``str_replace`` / ``bash`` / ``web_search``
/ ``agent_orchestration`` …), so parallel agents can't write files or dispatch.
Unified event protocol (one JSON object per ``data:`` frame)::
{"type": "agent_start", "agent_id": "..."}
{"type": "delta", "agent_id": "...", "text": "..."} # AI text increment
{"type": "step", "agent_id": "...", "key": "...", "label": "...", "args": {...}}
{"type": "agent_done", "agent_id": "...", "content": "..."}
{"type": "agent_error", "agent_id": "...", "message": "..."}
{"type": "all_done"}
The frame-normalisation (LangGraph ``messages-tuple`` → delta / tool step) mirrors
the frontend parser in ``frontend-web/src/roundtable-planning/api/multi-agent.ts``
(``extractAiTextDelta`` + ``tool_call_chunks`` accumulation).
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, AsyncIterator
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
from app.gateway.routers.multi_agent import (
_ROUNDTABLE_THREAD_METADATA,
_auth_headers,
_create_thread,
_loopback_base,
_make_loopback_client,
_special_run,
_validate_agent_id,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/parallel-agents", tags=["parallel-agents"])
# Soft cap on parallel degree — protects remote model concurrency + sandbox.
MAX_PARALLEL_AGENTS = 8
# ── frame normalisation (mirrors multi-agent.ts) ─────────────────────────────
def _extract_ai_text(chunk: dict[str, Any]) -> str:
"""AI text delta from a ``messages-tuple`` chunk obj; "" when not AI text."""
type_ = str(chunk.get("type") or "").lower()
if type_ not in ("ai", "aimessagechunk", "aimessage"):
return ""
content = chunk.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
out: list[str] = []
for item in content:
if isinstance(item, str):
out.append(item)
elif isinstance(item, dict):
t = item.get("text")
if isinstance(t, str):
out.append(t)
return "".join(out)
return ""
def _extract_tool_steps(chunk: dict[str, Any]) -> list[dict[str, Any]]:
"""Tool-call steps from a chunk obj — both complete ``tool_calls`` and
incremental ``tool_call_chunks`` (we only need the name to surface a step)."""
steps: list[dict[str, Any]] = []
tool_calls = chunk.get("tool_calls")
if isinstance(tool_calls, list):
for tc in tool_calls:
if not isinstance(tc, dict):
continue
name = tc.get("name")
if not isinstance(name, str) or not name:
continue
ident = tc.get("id") or name
args = tc.get("args") if isinstance(tc.get("args"), dict) else None
steps.append({"key": f"tool:{ident}", "label": f"调用工具 {name}", "args": args})
tcc = chunk.get("tool_call_chunks")
if isinstance(tcc, list):
for c in tcc:
if not isinstance(c, dict):
continue
name = c.get("name")
if not isinstance(name, str) or not name:
continue
idx = c.get("index") if isinstance(c.get("index"), int) else 0
ident = c.get("id") or f"{name}:{idx}"
steps.append({"key": f"tool:{ident}", "label": f"调用工具 {name}", "args": None})
return steps
def _build_task_message(task_name: str, brief: str) -> str:
parts: list[str] = []
if task_name.strip():
parts.append(f"任务名称:{task_name.strip()}")
if brief.strip():
parts.append(f"任务简介:\n{brief.strip()}")
parts.append(
"请你从你的专业职责出发,针对以上任务给出你的分析与交付。"
"本轮为**并行**研讨:请把你的产出**直接写在回复正文里**,"
"不要写文件、不要执行脚本/命令。"
)
return "\n\n".join(parts)
# ── endpoint ─────────────────────────────────────────────────────────────────
@router.post("/run/stream")
async def run_parallel_agents_stream(request: Request, payload: dict = Body(...)) -> StreamingResponse:
"""并行扇出 N 个智能体,合并成一条带 ``agent_id`` 标记的 SSE。文件写入固定禁用。"""
task_name: str = str(payload.get("task_name") or "").strip()
brief: str = str(payload.get("brief") or "").strip()
raw_ids = payload.get("agent_ids") or []
model_name: str = str(payload.get("model") or "")
if not isinstance(raw_ids, list) or not raw_ids:
raise HTTPException(status_code=400, detail="agent_ids 不能为空")
agent_ids: list[str] = []
for aid in raw_ids:
sid = str(aid or "").strip().lower()
_validate_agent_id(sid, "agent_ids")
if sid not in agent_ids:
agent_ids.append(sid)
if len(agent_ids) > MAX_PARALLEL_AGENTS:
raise HTTPException(
status_code=400,
detail=f"并行智能体数量上限 {MAX_PARALLEL_AGENTS}(当前 {len(agent_ids)})",
)
if not task_name and not brief:
raise HTTPException(status_code=400, detail="任务名称与简介至少填写其一")
base = _loopback_base(request)
headers = _auth_headers(request)
new_message = _build_task_message(task_name, brief)
async def _consume_agent(
client: Any,
agent_id: str,
queue: asyncio.Queue[dict[str, Any]],
) -> None:
await queue.put({"type": "agent_start", "agent_id": agent_id})
last_event = ""
seen_steps: set[str] = set()
buffer: list[str] = []
done_sent = False
try:
thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA)
gen = _special_run(
client,
base,
headers,
{agent_id: thread_id},
agent_id,
new_message,
[],
model_name,
display_name="",
no_file=True, # seat_parallel policy → 禁 write_file/bash/str_replace…
)
async for chunk in gen:
if isinstance(chunk, dict):
# 终态帧 {status, content, agent_name}
content = str(chunk.get("content") or "")
await queue.put({"type": "agent_done", "agent_id": agent_id, "content": content})
done_sent = True
continue
line = chunk.strip() if isinstance(chunk, str) else ""
if line.startswith("event:"):
last_event = line[6:].strip()
continue
if not line.startswith("data:"):
continue
raw = line[5:].strip()
if not raw:
continue
try:
parsed = json.loads(raw)
except Exception:
continue
if last_event == "messages" and isinstance(parsed, list) and parsed:
obj = parsed[0]
if isinstance(obj, dict):
text = _extract_ai_text(obj)
if text:
buffer.append(text)
await queue.put({"type": "delta", "agent_id": agent_id, "text": text})
for step in _extract_tool_steps(obj):
key = step["key"]
if key in seen_steps:
continue
seen_steps.add(key)
await queue.put({"type": "step", "agent_id": agent_id, **step})
if not done_sent:
# 流被抢占/截断,没收到终态帧 —— 用已累积正文兜底收口本席位。
await queue.put(
{"type": "agent_done", "agent_id": agent_id, "content": "".join(buffer)}
)
except Exception as exc: # noqa: BLE001 — 单个 agent 失败不拖垮其它
logger.warning("[parallel-agents] %s failed: %r", agent_id, exc)
await queue.put({"type": "agent_error", "agent_id": agent_id, "message": str(exc)})
finally:
await queue.put({"type": "__agent_end__", "agent_id": agent_id})
async def stream_generator() -> AsyncIterator[str]:
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async with _make_loopback_client(request) as client:
tasks = [asyncio.create_task(_consume_agent(client, aid, queue)) for aid in agent_ids]
ended = 0
try:
while ended < len(agent_ids):
event = await queue.get()
if event.get("type") == "__agent_end__":
ended += 1
continue
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps({'type': 'all_done'}, ensure_ascii=False)}\n\n"
finally:
for t in tasks:
if not t.done():
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
return StreamingResponse(
stream_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
@@ -0,0 +1,61 @@
"""Create parallel_agent_tasks table.
Saved runs of the standalone parallel multi-agent panel (Vue embeddable
component): task name + brief + agents snapshot + the resulting message list,
bound per user. ``brief`` / ``agents`` / ``messages`` are LONGTEXT on MySQL
(mirror ``roundtable_drafts`` headroom) and plain TEXT elsewhere — matching
``PortableLongText``. ``create_all`` builds the table for brand-new DBs; this
revision patches DBs that already exist.
Chained after ``20260608_04`` (chain stage_goals) so the revision graph stays
single-headed — otherwise ``alembic upgrade head`` aborts with "multiple heads"
and the startup auto-migrate silently falls back to the create_all schema.
Revision ID: 20260608_05
Revises: 20260608_04
Create Date: 2026-06-08
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260608_05"
down_revision = "20260608_04"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def upgrade() -> None:
if _has_table("parallel_agent_tasks"):
return
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
op.create_table(
"parallel_agent_tasks",
sa.Column("id", sa.String(length=64), primary_key=True, nullable=False),
sa.Column("user_id", sa.String(length=64), nullable=False),
sa.Column("title", sa.String(length=512), nullable=False, server_default=""),
sa.Column("brief", long_text, nullable=True),
sa.Column("agents", long_text, nullable=True),
sa.Column("messages", long_text, nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index(
"ix_parallel_agent_tasks_user_id", "parallel_agent_tasks", ["user_id"]
)
def downgrade() -> None:
if _has_table("parallel_agent_tasks"):
op.drop_index("ix_parallel_agent_tasks_user_id", table_name="parallel_agent_tasks")
op.drop_table("parallel_agent_tasks")
@@ -27,6 +27,7 @@ from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.notifications.model import NotificationRow
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow
@@ -62,6 +63,7 @@ __all__ = [
"AdminLeaderboardDailyStatRow",
"LlmCallMetricRow",
"NotificationRow",
"ParallelAgentTaskRow",
"RecommendedQuestionRow",
"RoundtableChainRow",
"RoundtableDraftRow",
@@ -0,0 +1,18 @@
"""Parallel multi-agent task persistence."""
from __future__ import annotations
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
from deerflow.persistence.parallel_agent_tasks.sql import ParallelAgentTaskRepository
__all__ = [
"ParallelAgentTaskRepository",
"ParallelAgentTaskRow",
"make_parallel_agent_task_store",
]
def make_parallel_agent_task_store(session_factory):
if session_factory is None:
return None
return ParallelAgentTaskRepository(session_factory)
@@ -0,0 +1,46 @@
"""ORM model for parallel multi-agent task records.
A "parallel-agent task" is one saved run of the standalone *parallel multi-agent*
panel (the Vue3 embeddable component): a task name + brief dispatched to several
agents in parallel, plus the resulting chat/message list. Persisted per user so it
follows the account across devices/sessions — same model as ``roundtable_drafts``.
``agents`` and ``messages`` are stored as JSON serialized into ``PortableLongText``
(MySQL ``LONGTEXT``) rather than ``PortableJSON`` (MySQL ``TEXT``, 64 KB cap),
because the parallel transcript can carry several agents' full deliveries that
easily exceed 64 KB — mirroring ``roundtable_drafts``.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableLongText
class ParallelAgentTaskRow(Base):
__tablename__ = "parallel_agent_tasks"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
# Task name (用户输入的任务名称) shown on the history list.
title: Mapped[str] = mapped_column(String(512), nullable=False, default="")
# Task brief / 简介 — the free-text task description.
brief: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# JSON: snapshot of the agents dispatched, [{"agent_id", "name", "avatarType"?}].
agents: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# JSON: the full message/dialogue list (mirrors the frontend Dialogue[] shape).
messages: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
@@ -0,0 +1,131 @@
"""SQLAlchemy-backed store for parallel multi-agent task records.
``agents`` / ``messages`` JSON columns are stored as serialized strings in
``PortableLongText`` and round-tripped here with ``json.dumps`` / ``json.loads``
— same approach as ``roundtable_drafts`` / ``roundtable_chains``.
All write/read paths are scoped by ``user_id`` for ownership isolation; a row
that belongs to another user is treated as not found.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
_UNSET = object()
def _loads(value: Any) -> Any:
if not isinstance(value, str):
return value
if not value:
return None
try:
return json.loads(value)
except Exception:
return None
def _dumps(value: Any) -> str | None:
if value is None:
return None
return json.dumps(value, ensure_ascii=False)
class ParallelAgentTaskRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _to_dict(row: ParallelAgentTaskRow, *, with_messages: bool = True) -> dict[str, Any]:
data: dict[str, Any] = {
"id": row.id,
"title": row.title,
"brief": row.brief,
"agents": _loads(row.agents) or [],
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
}
if with_messages:
data["messages"] = _loads(row.messages) or []
return data
async def list_tasks(self, user_id: str, *, limit: int = 200) -> list[dict[str, Any]]:
"""列出当前用户的任务(轻量元信息,不含 messages,供历史列表)。"""
stmt = (
select(ParallelAgentTaskRow)
.where(ParallelAgentTaskRow.user_id == user_id)
.order_by(ParallelAgentTaskRow.updated_at.desc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._to_dict(row, with_messages=False) for row in result.scalars()]
async def get_task(self, task_id: str, user_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ParallelAgentTaskRow, task_id)
if row is None or row.user_id != user_id:
return None
return self._to_dict(row)
async def create_task(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
row = ParallelAgentTaskRow(
id=data["id"],
user_id=user_id,
title=(data.get("title") or "").strip(),
brief=(data.get("brief") or None),
agents=_dumps(data.get("agents") or []),
messages=_dumps(data.get("messages") or []),
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._to_dict(row)
async def update_task(
self,
task_id: str,
user_id: str,
*,
title=_UNSET,
brief=_UNSET,
agents=_UNSET,
messages=_UNSET,
) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ParallelAgentTaskRow, task_id)
if row is None or row.user_id != user_id:
return None
if title is not _UNSET and title is not None:
row.title = str(title).strip()
if brief is not _UNSET:
row.brief = brief or None
if agents is not _UNSET:
row.agents = _dumps(agents)
if messages is not _UNSET:
row.messages = _dumps(messages)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._to_dict(row)
async def delete_task(self, task_id: str, user_id: str) -> bool:
async with self._sf() as session:
row = await session.get(ParallelAgentTaskRow, task_id)
if row is None or row.user_id != user_id:
return False
await session.delete(row)
await session.commit()
return True
+79
View File
@@ -0,0 +1,79 @@
# parallel-agents-vue
并行多智能体面板 —— Vue3 可嵌入组件。输入「任务名称 + 简介」,**并行调用多个智能体**
(智能体清单由宿主传入,运行期间后端固定**禁止写文件**),消息列表照搬 DeerFlow 圆桌
研讨第二步并行多席位的**深色样式与完整结构**(步骤条 / 并行双列 / 席位状态条),支持
**保存聊天记录 + 回显**与**重新分析**。
## 开发调试
```bash
pnpm install
pnpm dev # http://localhost:5183 ,填后端 baseUrl / token / 智能体 id 即可联调
```
后端需要一个跑着的 DeerFlow 网关(默认 `http://localhost:8001`),它提供:
- `POST /api/parallel-agents/run/stream` —— 并行扇出,合并 SSE(事件带 `agent_id`);
- `/api/parallel-agent-tasks` —— 任务保存 / 回显 CRUD。
## 构建为组件库
```bash
pnpm build:lib # 产出 dist/parallel-agents-vue.{js,umd.cjs} + dist/parallel-agents-vue.css
```
## 嵌入其它 Vue 项目
```ts
import { ParallelAgentsPanel } from "parallel-agents-vue";
import "parallel-agents-vue/style.css";
```
```vue
<!-- 放进你自己的抽屉 / 弹窗里,面板会自适应容器(h-full w-full)。
接口地址走配置文件默认(src/config.ts),登录只需把用户名传进来(免密自动登录)。 -->
<YourDrawer v-model="open">
<div style="height: 80vh">
<ParallelAgentsPanel
:username="currentUsername"
:agents="[
{ agent_id: 'roundtable-intelligence', name: '情报收集' },
{ agent_id: 'roundtable-risk-review', name: '风险审查' },
]"
@saved="onSaved"
/>
</div>
</YourDrawer>
```
### 接口地址 & 登录
- **接口地址**写在配置文件 `src/config.ts` 的 `DEFAULT_BASE_URL`(dev 可用 `VITE_PA_BASE_URL` 覆盖);
也可用 `:base-url` prop 覆盖。
- **登录**:把宿主侧的**用户名**通过 `:username` 传进来即可,组件会自动走 DeerFlow 的
**免密「用户名登录」**端点(`POST /api/v1/auth/login/username`,公共 + CSRF 豁免)换取 token
并缓存、自动续期(401 重登)。**不需要宿主自己管 token**。
- 若你已有 token,也可直接传 `:token` / `:get-token`(优先级高于 username)。
- 若后端开了口令门(`auth_login.username_login.require_password=true`),再传 `:login-password`。
### Props
| prop | 类型 | 说明 |
| --------------- | -------------------------------------- | ----------------------------------------------------- |
| `agents` | `{agent_id,name,avatarType?}[]`(必填)| 要并行调用的智能体 |
| `username` | `string` | 免密自动登录用户名(推荐方式,宿主自己传) |
| `baseUrl` | `string` | 后端地址;不传用 `config.ts` 默认 |
| `token` | `string` | 已有 Bearer token(与 `username` 二选一,优先) |
| `getToken` | `() => string \| Promise<string>` | 动态取 token |
| `loginPassword` | `string` | 仅当后端开启口令门时需要 |
| `taskId` | `string` | 传入则挂载时自动加载并回显该已保存任务 |
| `model` | `string` | 可选模型名(不传用 `config.ts` 默认 / 后端回退) |
| `dark` | `boolean`(默认 `true`) | 深色面板(自带成色,不依赖宿主主题) |
### 事件
- `@saved="(task) => ..."` —— 保存成功(含新建/更新)后回传 `SavedTask`。
- `@update:taskId="(id) => ..."` —— 当前任务 id 变化(保存 / 加载历史时)。
> 样式作用域全部包在根类 `.parallel-agents` 下,且**不引入 Tailwind preflight**,不会污染宿主全局样式。
@@ -0,0 +1,47 @@
# 并行多智能体面板 · 测试用例总览
本目录是 `parallel-agents-vue` 的**手工测试用例**(Markdown)。覆盖:并行调用与流式、文件写入禁用、
保存与回显、重新分析、免密登录与鉴权、嵌入与深色样式。
## 用例清单
| 文件 | 主题 |
| -------------------------------------------- | ------------------------------------- |
| [01-并行调用与流式.md](./01-并行调用与流式.md) | 并行扇出、流式输出、步骤条、席位状态条、禁写文件 |
| [02-保存与回显.md](./02-保存与回显.md) | 保存聊天记录、历史列表、加载回显、更新保存 |
| [03-重新分析.md](./03-重新分析.md) | 重新分析(覆盖重跑)、停止、空输入校验 |
| [04-免密登录与鉴权.md](./04-免密登录与鉴权.md) | username 免密登录、token 优先级、401 重登、口令门 |
| [05-嵌入与深色样式.md](./05-嵌入与深色样式.md) | 配置文件接口地址、抽屉/弹窗嵌入、深色自适应、不污染宿主 |
| [06-可用智能体清单.md](./06-可用智能体清单.md) | **测试用真实 agent_id 清单(可直接复制粘贴)** + 示例任务 |
| [07-使用说明.md](./07-使用说明.md) | **在其它 Vue 项目中引入哪些组件、如何使用**(props/事件/嵌入) |
## 测试环境
| 组件 | 地址 / 命令 |
| --------------- | ------------------------------------------------------ |
| DeerFlow 后端 | `http://localhost:8001`(`make dev`,需含本功能的新路由)|
| Vue dev demo | `http://localhost:5183`(`pnpm dev`) |
| 接口地址配置 | `src/config.ts` 的 `DEFAULT_BASE_URL` |
## 前置准备(所有用例通用)
1. 后端已启动,且包含本功能:
- `POST /api/parallel-agents/run/stream`(并行扇出,合并 SSE)
- `GET/POST/PUT/DELETE /api/parallel-agent-tasks`(任务保存/回显)
- `POST /api/v1/auth/login/username`(免密登录,公共)
- 数据表 `parallel_agent_tasks` 已建(`alembic upgrade head` 或 `create_all`)。
2. 至少准备 **2 个真实存在的 agent_id**(必须是后端注册过的真实 id,不能只写名字)。
本环境可直接用:`roundtable-solution-design`(方案设计)、`roundtable-risk-review`(风险审查)等
—— 完整可复制清单见 [06-可用智能体清单.md](./06-可用智能体清单.md)。
3. 打开 dev demo `http://localhost:5183`,填:
- 后端 baseUrl:`http://localhost:8001`
- 用户名:`demo`(免密自动登录)
- 智能体:每行 `agent_id, 显示名`
点「打开面板(弹窗内)」。
## 用例字段约定
- **用例ID**:`PA-<模块>-<序号>`
- **优先级**:P0(核心,必过)/ P1(重要)/ P2(边界)
- **前置条件 → 操作步骤 → 预期结果**:逐条核对,全部命中才算通过。
- **结果记录**:建议在「实际结果 / 通过?」列里回填。
@@ -0,0 +1,105 @@
# 测试用例 · 并行调用与流式
覆盖:并行扇出多个智能体、流式逐字输出、步骤条(工具调用)、点头像切换显示(单选)、席位状态条、
运行期间**禁止写文件**。
---
## PA-RUN-01 · 并行调用多个智能体(P0)
**前置条件**
- 面板已打开,填入任务名「竞品调研」、简介「对比 A、B 两款产品的核心功能与定价」。
- 传入 2 个智能体(情报收集、风险审查)。
**操作步骤**
1. 点击「开始分析」。
**预期结果**
- 顶部出现**席位状态条**,显示「并行 2 席」,两个席位头像角标先后转为「进行中」(转圈)。
- 两个智能体**同时**开始执行(并行,而非一个跑完再跑下一个)——可在状态条上看到都在「进行中」。
- 会话区每次显示**一个**席位的消息列表(默认第 1 个),点头像可切换查看另一个(见 PA-RUN-06)。
- 气泡头部显示:头像、智能体名、状态徽标「并行执行中」、时间。
---
## PA-RUN-02 · 流式逐字输出 + 光标(P0)
**前置条件**:PA-RUN-01 正在运行中。
**操作步骤**
1. 观察当前显示席位的气泡正文(点头像可切换看其它席位)。
**预期结果**
- 正文**逐步增量**出现(流式),不是一次性整段刷出。
- 正在输出的气泡末尾有**闪烁光标**(`.pa-cursor`)。
- 内容还没来时,显示 loading 占位(转圈 + 跳动圆点 + 「并行执行中」文案)。
- Markdown 能正确渲染(标题/列表/代码块/表格按 `.pa-md` 样式)。
---
## PA-RUN-03 · 步骤条(思维链 / 工具调用)(P1)
**前置条件**:智能体会调用工具(如检索类技能)。
**操作步骤**
1. 运行一轮,观察气泡顶部的「执行步骤」折叠卡。
**预期结果**
- 出现「执行步骤(N)」折叠卡(`MessageStepsCard`),点击可展开/收起。
- 每条步骤显示:工具标签(如「调用工具 xxx」)、参数摘要(如有 `path` 优先显示)、时间。
- 同一工具调用**不重复**出条目(按 key 去重)。
- 若智能体本轮不调工具,则不出现步骤卡(属正常)。
---
## PA-RUN-04 · 席位状态流转(P1)
**操作步骤**
1. 运行一轮,全程观察席位状态条各头像角标。
**预期结果**
- 角标状态依次:灰(pending)→ 蓝色转圈(running)→ 绿色对勾(done)。
- 某智能体失败时其角标变**红色叉**(error),且对应气泡显示「该智能体执行出错:…」、状态徽标「出错」。
- 单个智能体失败**不影响**其它智能体继续完成(互不拖累)。
- 全部结束后,运行按钮从「停止」恢复为「重新分析」。
---
## PA-RUN-05 · 运行期间禁止写文件(P0)
**目的**:核对后端 `seat_parallel` 策略生效——并行席位被禁用 `write_file` / `str_replace` / `bash` 等。
**操作步骤**
1. 简介里**故意要求写文件**,例如:「请把结果保存成一个 .md 文件」。
2. 点击「开始分析」,等待完成。
**预期结果**
- 智能体**不会**真正落地文件,产出**直接呈现在气泡正文**里。
- 步骤条里**不出现** `write_file` / `bash` / `str_replace` 工具调用(被策略排除)。
- 可旁证:后端日志中该席位 `role=seat_parallel`。
---
## PA-RUN-06 · 点头像切换显示(单选)(P1)
**前置条件**:传入 ≥2 个智能体并运行。
**操作步骤**
1. 点击席位状态条上的某个头像。
**预期结果**
- 主区**切换为只显示**该智能体的消息列表(**单选**,每次显示一个)。
- 被选中的头像高亮(外发光描边);再点别的头像即切到那个。
- 切换不影响后台并行执行:未显示的智能体仍在跑/已完成,其增量在切回时能看到。
- 默认显示第 1 个席位。
---
## PA-RUN-07 · 后端不可达 / 智能体 id 非法(P2)
**操作步骤**
1. 把 baseUrl 改成一个打不通的地址,或传入不存在的 agent_id,点「开始分析」。
**预期结果**
- 顶部出现红色错误提示(请求失败信息),不白屏、不卡死。
- 非法 agent_id 时,对应席位以 error 角标 + 出错气泡呈现,其它正常席位不受影响。
@@ -0,0 +1,94 @@
# 测试用例 · 保存与回显
覆盖:保存聊天记录(新建/更新)、历史列表、加载已存任务并回显、`taskId` 自动加载、跨刷新持久化。
---
## PA-SAVE-01 · 首次保存(新建)(P0)
**前置条件**:已跑完一轮、会话区有结果。任务名「竞品调研」。
**操作步骤**
1. 点击「保存」。
**预期结果**
- 按钮短暂显示「保存中…」,成功后变为「更新保存」(说明已绑定 taskId)。
- 触发 `@saved` 事件,回传 `SavedTask`(含 id、title、agents、messages)。
- 「历史」下拉里出现这条记录(标题 + 更新时间)。
- 后端 `parallel_agent_tasks` 表新增一行(owner = 当前登录用户)。
---
## PA-SAVE-02 · 再次保存(更新覆盖)(P0)
**前置条件**:PA-SAVE-01 已保存、当前仍是同一任务。
**操作步骤**
1. 修改任务名 / 简介,或重新分析得到新结果。
2. 点击「更新保存」。
**预期结果**
- **不新增**记录,而是更新原 taskId 那条(title / brief / agents / messages 被覆盖为最新)。
- 历史列表里该条的更新时间刷新、排到最前。
---
## PA-SAVE-03 · 历史加载并回显(P0)
**前置条件**:历史里至少有 1 条已保存任务。
**操作步骤**
1. 点击「历史 ▾」展开列表。
2. 选择一条历史任务。
**预期结果**
- 任务名、简介**回填**为该任务保存时的值。
- 会话区**完整回显**保存时的消息列表(各智能体气泡、正文、步骤条),且**均为非流式终态**(无光标、无 loading)。
- 席位状态条据消息重建:正常席位显示 done(绿勾),出错的显示 error(红叉)。
- 当前 taskId 切换为该任务(再点「保存」是「更新保存」该条)。
---
## PA-SAVE-04 · taskId 进入即自动回显(P1)
**操作步骤**
1. 以 `:task-id="<已存在任务id>"` 方式挂载面板(嵌入场景)。
**预期结果**
- 面板挂载后**自动加载**该任务并回显(等同于 PA-SAVE-03 的结果),无需手动点历史。
- `@update:taskId` 随之触发。
---
## PA-SAVE-05 · 跨刷新持久化(P1)
**操作步骤**
1. 保存一条任务后,刷新浏览器(或重开宿主页面)。
2. 用相同用户名进入,打开历史。
**预期结果**
- 历史里仍能看到之前保存的任务(数据存在后端、按用户隔离,非浏览器 localStorage)。
- 加载后能正常回显。
---
## PA-SAVE-06 · 无结果时禁止保存(P2)
**操作步骤**
1. 新开面板、尚未运行任何分析。
**预期结果**
- 「保存」按钮**禁用**(无可保存内容)。
- 运行中也不应因保存导致状态错乱(保存按钮在有结果后才可用)。
---
## PA-SAVE-07 · 用户隔离(P1)
**操作步骤**
1. 用户名 A 保存一条任务。
2. 切换用户名为 B(重新进入面板),打开历史。
**预期结果**
- B 的历史里**看不到** A 的任务。
- B 直接用 A 的 taskId 加载应失败(404 / 无内容),不报错崩溃。
@@ -0,0 +1,77 @@
# 测试用例 · 重新分析与运行控制
覆盖:重新分析(覆盖重跑)、运行中停止、空输入校验、运行态按钮切换。
---
## PA-RE-01 · 重新分析(覆盖重跑)(P0)
**前置条件**:已跑完一轮、会话区有结果(按钮显示「重新分析」)。
**操作步骤**
1. (可选)修改任务名 / 简介。
2. 点击「重新分析」。
**预期结果**
- **清空**上一轮的所有气泡与席位状态,按当前输入**重新并行**调用各智能体。
- 不会和上一轮结果混在一起(旧消息被替换,不是追加)。
- 重跑过程与首跑一致(流式、单列消息 + 点头像切换、步骤条、席位状态流转)。
---
## PA-RE-02 · 运行中按钮为「停止」(P1)
**操作步骤**
1. 开始分析,运行未结束时观察操作区。
**预期结果**
- 运行期间,主按钮显示「停止」(不再是「开始/重新分析」)。
- 「保存」按钮在运行中可点(保存当前已产出的内容)或按实现禁用——以不报错为准。
---
## PA-RE-03 · 停止当前运行(P0)
**前置条件**:正在运行中。
**操作步骤**
1. 点击「停止」。
**预期结果**
- 进行中的请求被取消(AbortController),不再有新增量进来。
- 仍在 streaming 的气泡被收尾为终态(去掉光标,徽标置「已完成」)。
- 按钮恢复为「重新分析」,可立即再次发起。
- 控制台不应出现未捕获的 AbortError 报错(已被吞掉)。
---
## PA-RE-04 · 空输入校验(P1)
**操作步骤**
1. 任务名与简介**都留空**,点击「开始分析」。
**预期结果**
- 按钮处于**禁用**态,点击无效(`canStart` 为假需任务名或简介至少其一 + 至少一个智能体)。
- 只填任务名或只填简介,按钮可用。
---
## PA-RE-05 · 未传智能体(P2)
**操作步骤**
1. `:agents="[]"`(空数组)挂载面板。
**预期结果**
- 操作区显示提示「⚠️ 未传入任何智能体(agents 为空)」。
- 「开始分析」禁用,不会发起空请求。
---
## PA-RE-06 · 连续多次重新分析(P2)
**操作步骤**
1. 连续点击「重新分析」若干次(每次等上一轮结束或中途停止再点)。
**预期结果**
- 每次都干净重置 + 重跑,无残留旧气泡、无席位状态错乱。
- 不出现多份 SSE 叠加导致的内容串列 / 重复。
@@ -0,0 +1,106 @@
# 测试用例 · 免密登录与鉴权
覆盖:username 免密自动登录、token 优先级、token 缓存与 401 重登、口令门、CSRF 绕过。
> 背景:面板优先用宿主传入的 token;没有 token 时,用宿主传入的 **username** 走 DeerFlow 公共免密端点
> `POST /api/v1/auth/login/username` 换取 `access_token`,以 `Authorization: Bearer` 发起后续请求
> (带 Bearer 会绕过 CSRF)。token 解析优先级:`token` > `getToken` > 缓存 > `username` 登录。
---
## PA-AUTH-01 · username 免密自动登录(P0)
**前置条件**:后端 `auth_login.username_login.require_password = false`(默认)。
**操作步骤**
1. 只传 `:username="demo"`(不传 token),打开面板。
2. 开始分析 / 打开历史。
**预期结果**
- 面板能正常拉历史、能发起并行流式——说明已用 username 换到 token 并附带在请求头。
- 旁证(手工 curl):
```bash
curl -s -X POST -H "Content-Type: application/json" \
-d '{"username":"demo"}' http://localhost:8001/api/v1/auth/login/username
# → 返回 {"access_token":"...","token_type":"bearer",...}
```
---
## PA-AUTH-02 · token 优先级高于 username(P1)
**操作步骤**
1. 同时传 `:token="<有效token>"` 和 `:username="demo"`。
2. 触发请求(开始分析 / 历史)。
**预期结果**
- 直接用传入的 `token`(不会再去调登录端点)。
- `:get-token` 同理优先于 username(动态取 token)。
---
## PA-AUTH-03 · token 缓存(不重复登录)(P1)
**操作步骤**
1. 只传 username,先后触发多次请求(拉历史 → 开始分析 → 保存)。
**预期结果**
- 登录端点**只调用一次**(首个请求),后续请求复用缓存 token(按 `baseUrl::username` 缓存)。
- 可在浏览器 Network 面板核对 `login/username` 只出现一次。
---
## PA-AUTH-04 · 401 自动重登重试(P1)
**前置条件**:能制造 token 失效(例如等待过期,或后端清理)。
**操作步骤**
1. 用 username 登录并缓存 token 后,使其失效,再触发一次 CRUD 请求。
**预期结果**
- 首次请求收到 401 → 面板**清缓存并用 username 重登一次**,再自动重试该请求。
- 用户无感知(不需手动重新登录),请求最终成功。
---
## PA-AUTH-05 · 口令门(require_password=true)(P2)
**前置条件**:后端把 `auth_login.username_login.require_password` 设为 `true`、配置 `password`。
**操作步骤**
1. 只传 username、不传 `loginPassword` → 触发请求。
2. 再传上正确的 `:login-password="<口令>"` → 触发请求。
**预期结果**
- 不传口令时:登录返回 401「口令错误」,面板顶部红色错误提示,不崩溃。
- 传对口令时:登录成功,后续请求正常。
---
## PA-AUTH-06 · Bearer 绕过 CSRF(P1)
**目的**:核对带 Bearer 的 POST 不被 CSRF 拦截。
**操作步骤(手工 curl 旁证)**
```bash
TOKEN=... # 上面登录拿到的 access_token
# 带 Bearer 的 POST:参数非法应为 400/422,而非 403(403=CSRF 拦截)
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{}' http://localhost:8001/api/parallel-agents/run/stream
```
**预期结果**
- 返回 **400/422**(业务参数校验),**不是 403**——证明 Bearer 已绕过 CSRF。
- 不带任何鉴权时该 POST 返回 401/403(被拦截),属正常。
---
## PA-AUTH-07 · 未传 token 且未传 username(P2)
**操作步骤**
1. 既不传 token / getToken,也不传 username,触发需要鉴权的请求。
**预期结果**
- 请求因缺少鉴权失败(401/403),面板给出可读的错误提示(不白屏)。
- 控制台/提示能反映「缺少 token 且未提供 username,无法登录」之类信息。
@@ -0,0 +1,105 @@
# 测试用例 · 嵌入与深色样式
覆盖:接口地址走配置文件、组件库构建产物、嵌入其它 Vue 项目、放进抽屉/弹窗自适应、深色成色、
不污染宿主全局样式。
---
## PA-EMBED-01 · 接口地址走配置文件(P0)
**操作步骤**
1. 不传 `:base-url`,只改 `src/config.ts` 的 `DEFAULT_BASE_URL` 为目标后端地址。
2. 运行面板触发请求。
**预期结果**
- 请求打到配置文件里的地址。
- 传 `:base-url` prop 时**优先**用 prop(覆盖配置默认)。
- dev 下设 `VITE_PA_BASE_URL` 环境变量也能覆盖默认。
---
## PA-EMBED-02 · 组件库构建产物(P0)
**操作步骤**
```bash
pnpm build:lib
```
**预期结果**
- `dist/` 产出:`parallel-agents-vue.js`(ESM)、`parallel-agents-vue.umd.cjs`(UMD)、`parallel-agents-vue.css`。
- `vue` 被 external(不打进库,由宿主提供)。
- 构建无报错、无「named + default exports」警告。
---
## PA-EMBED-03 · 嵌入其它 Vue 项目(P0)
**操作步骤**
1. 在另一个 Vue3 项目里:
```ts
import { ParallelAgentsPanel } from "parallel-agents-vue";
import "parallel-agents-vue/style.css";
```
2. 模板中放置:
```vue
<ParallelAgentsPanel :username="user" :agents="agents" />
```
**预期结果**
- 组件正常渲染、可交互(深色面板,与 demo 一致)。
- 只需 `:username` + `:agents` 两个 prop 即可跑起来(接口地址走配置)。
- 也支持 `app.use(ParallelAgentsVue)` 全局注册后用 `<ParallelAgentsPanel/>`。
---
## PA-EMBED-04 · 放进抽屉 / 弹窗自适应(P0)
**操作步骤**
1. 把面板放进宿主的抽屉 / 对话框,外层给一个固定高度容器:
```vue
<YourDrawer v-model="open">
<div style="height: 80vh"><ParallelAgentsPanel :username="user" :agents="agents" /></div>
</YourDrawer>
```
2. 改变容器尺寸 / 视口大小。
**预期结果**
- 面板**充满容器**(根元素 `h-full w-full flex flex-col min-h-0`),不溢出、不塌缩。
- 会话区内部独立滚动,输入区与席位条固定不被挤掉。
- 窄容器下消息列表正常显示、内部滚动,不溢出。
---
## PA-EMBED-05 · 深色成色自带、与宿主主题无关(P1)
**操作步骤**
1. 分别在**浅色**宿主页和**深色**宿主页里嵌入面板。
**预期结果**
- 面板始终是深蓝深色(自带 `--pa-*` token,作用域在 `.parallel-agents`),不随宿主明暗变化。
- 文案、徽标、步骤卡、并行列底、滚动条等观感与圆桌 Step2 深色一致。
- 传 `:dark="false"` 时按实现切换(如未实现浅色则保持深色,至少不报错)。
---
## PA-EMBED-06 · 不污染宿主全局样式(P0)
**目的**:核对**未引入 Tailwind preflight**(不重置宿主全局标签样式)。
**操作步骤**
1. 在一个有自己样式的宿主页里嵌入面板(页面有 `h1/p/button/ul` 等默认样式)。
**预期结果**
- 嵌入后,宿主页面**自身**的 `h1/p/button/列表/边距` 等样式**不被改变**(没有被 Tailwind 的 reset 抹平)。
- 面板内部样式正常(不依赖宿主提供 Tailwind)。
- 类名都在 `.parallel-agents` 作用域下,不与宿主类名冲突。
---
## PA-EMBED-07 · 多实例并存(P2)
**操作步骤**
1. 同一页面放置 2 个面板实例(不同 agents / 不同 username)。
**预期结果**
- 两个实例各自独立运行、各自保存/回显,互不串流、互不串 token 缓存(缓存按 `baseUrl::username` 区分)。
@@ -0,0 +1,73 @@
# 测试用 · 可用智能体清单(可直接复制粘贴)
测试时面板需要传入**真实存在的 `agent_id`**(后端真正拿去跑的标识,不能只写名字)。
下面是当前后端里**可直接用于测试**的内置智能体(已排除 intent / recommender / coordinator 这些系统角色)。
> 注:`name` 只是显示用、可省略;`agent_id` 必须照抄。下列为本环境实测可用的内置智能体;
> 你部署里的智能体可能不同,用文末方法自查。
## 可用智能体
| agent_id | 显示名 |
| --------------------------- | -------- |
| `roundtable-solution-design`| 方案设计 |
| `roundtable-risk-review` | 风险审查 |
| `roundtable-execution-plan` | 执行规划 |
| `roundtable-report` | 方案总结 |
| `demo-expert` | 演示专家 |
## A. 粘贴进 dev demo 页「智能体」输入框(每行 `agent_id, 显示名`)
推荐至少 2 个以便点头像切换查看不同智能体:
```
roundtable-solution-design, 方案设计
roundtable-risk-review, 风险审查
```
想多看几列就多贴几行:
```
roundtable-solution-design, 方案设计
roundtable-risk-review, 风险审查
roundtable-execution-plan, 执行规划
```
## B. 粘贴进组件 `:agents` prop(嵌入场景)
```ts
const agents = [
{ agent_id: "roundtable-solution-design", name: "方案设计" },
{ agent_id: "roundtable-risk-review", name: "风险审查" },
{ agent_id: "roundtable-execution-plan", name: "执行规划" },
];
```
```vue
<ParallelAgentsPanel :username="'demo'" :agents="agents" />
```
## C. 配一个示例任务(直接粘贴到面板)
- **任务名称**:`新功能上线方案评估`
- **任务简介**:
```
我们要给 App 上线「扫码点餐」功能。请从你的专业角度,评估方案可行性、列出关键风险、
并给出落地步骤与里程碑。
```
## 自查:列出你部署里所有可用的 agent_id
```bash
# 1) 免密登录拿 token(用户名换成你的)
TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \
-d '{"username":"demo"}' http://localhost:8001/api/v1/auth/login/username \
| python -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
# 2) 列出 agent_id <TAB> name
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8001/api/agents -o agents.json
python -c "import json,io;[print(a['id'],'\t',a['name']) for a in json.load(io.open('agents.json',encoding='utf-8')).get('agents',[])]"
```
第一列 `id` 才是要填的 `agent_id`。挑你需要的智能体即可(避开 `roundtable-intent` /
`roundtable-recommender` / `roundtable-coordinator*` 这些系统角色——它们不是用来当并行席位的)。
+163
View File
@@ -0,0 +1,163 @@
# 使用说明 · 在其它 Vue 项目中引入
本组件库只对外暴露**一个**组件 `ParallelAgentsPanel`(其余 `MessageBubble` / `MessageStepsCard` /
`ParallelSeatStrip` / `Avatar` / `MarkdownContent` 都是它的内部子组件,**无需也不要单独引入**)。
宿主只管把它放进自己的页面 / 抽屉 / 弹窗,并传几个 prop。
---
## 一、要引入的东西(就这两样)
1. **组件**:`ParallelAgentsPanel`
2. **样式**:`parallel-agents-vue/style.css`(一次性引入即可,样式作用域全在 `.parallel-agents` 下)
> 依赖说明:`vue ^3.4`(宿主提供,库不打包 vue);`markdown-it` 已打进库内,无需宿主安装。
---
## 二、两种引入方式
### 方式 A:构建成库后按包引入(推荐)
1. 在本项目构建:
```bash
pnpm install
pnpm build:lib # 产出 dist/parallel-agents-vue.{js,umd.cjs,css}
```
2. 宿主项目里安装(任选其一):
- 把本目录作为本地依赖:`"parallel-agents-vue": "file:../parallel-agents-vue"`;
- 或发到你们的私有 npm 后正常 `pnpm add parallel-agents-vue`。
3. 代码里引入:
```ts
import { ParallelAgentsPanel } from "parallel-agents-vue";
import "parallel-agents-vue/style.css";
```
### 方式 B:直接拷贝源码(无需发包)
把本项目 `src/` 下这些拷进宿主项目(保持相对结构),按相对路径引入 `ParallelAgentsPanel.vue` +
`styles/parallel-agents.css` 即可:
```
src/
components/ ParallelAgentsPanel.vue · MessageBubble.vue · MessageStepsCard.vue
ParallelSeatStrip.vue · Avatar.vue · MarkdownContent.vue
composables/ useParallelRun.ts
lib/ avatar-constants.ts
styles/ parallel-agents.css
api.ts · config.ts · types.ts
```
宿主需有 `markdown-it` 依赖(`pnpm add markdown-it`),并保证构建支持 `.vue`(Vite/@vitejs/plugin-vue)。
---
## 三、注册与使用
### 局部引入(推荐)
```vue
<script setup lang="ts">
import { ParallelAgentsPanel } from "parallel-agents-vue";
import "parallel-agents-vue/style.css";
const agents = [
{ agent_id: "roundtable-solution-design", name: "方案设计" },
{ agent_id: "roundtable-risk-review", name: "风险审查" },
];
</script>
<template>
<div style="height: 80vh">
<ParallelAgentsPanel :username="'demo'" :agents="agents" />
</div>
</template>
```
### 全局注册(可选)
```ts
import ParallelAgentsVue from "parallel-agents-vue";
import "parallel-agents-vue/style.css";
app.use(ParallelAgentsVue); // 之后任意模板可直接用 <ParallelAgentsPanel/>
```
---
## 四、放进抽屉 / 弹窗
组件根元素是 `h-full w-full flex flex-col min-h-0`,会**充满父容器**。所以**父容器要给一个高度**
(弹窗/抽屉本身有高度即可):
```vue
<YourDrawer v-model="open" size="large">
<div style="height: 86vh">
<ParallelAgentsPanel :username="currentUsername" :agents="agents" @saved="onSaved" />
</div>
</YourDrawer>
```
---
## 五、接口地址与登录
- **接口地址**:默认读 `src/config.ts` 的 `DEFAULT_BASE_URL`(可改源码,或 dev 设 `VITE_PA_BASE_URL`);
也可用 `:base-url` prop 临时覆盖。
- **登录**:把宿主的**用户名**用 `:username` 传进来即可——组件自动走 DeerFlow 免密登录端点换 token、
缓存、401 自动重登。**宿主不用管 token**。若已有 token,可改传 `:token` / `:get-token`(优先级更高)。
- token 缓存按 `baseUrl::username` 维度,多个实例/多用户互不串。
---
## 六、Props
| prop | 类型 | 必填 | 说明 |
| --------------- | --------------------------------------- | :--: | ----------------------------------------------- |
| `agents` | `{ agent_id, name, avatarType? }[]` | ✔ | 要并行调用的智能体(`agent_id` 必须是真实 id) |
| `username` | `string` | | 免密自动登录用户名(推荐方式) |
| `baseUrl` | `string` | | 后端地址;不传用 `config.ts` 默认 |
| `token` | `string` | | 已有 Bearer token(优先于 username) |
| `getToken` | `() => string \| Promise<string>` | | 动态取 token |
| `loginPassword` | `string` | | 仅当后端开启口令门时需要 |
| `taskId` | `string` | | 传入则挂载时自动加载并回显该任务 |
| `model` | `string` | | 可选模型名(不传走默认 / 后端回退) |
| `dark` | `boolean`(默认 `true`) | | 深色面板(自带成色,不依赖宿主主题) |
`AgentInput`/`SavedTask` 等类型也从包里导出:`import type { AgentInput, SavedTask } from "parallel-agents-vue"`。
## 七、事件
| 事件 | 载荷 | 触发时机 |
| ----------------- | ------------ | --------------------------------------- |
| `saved` | `SavedTask` | 保存成功(新建或更新)后 |
| `update:taskId` | `string` | 当前任务 id 变化(保存 / 加载历史时) |
```vue
<ParallelAgentsPanel
:username="user"
:agents="agents"
:task-id="taskId"
@saved="(t) => console.log('saved', t.id)"
@update:taskId="(id) => (taskId = id)"
/>
```
---
## 八、交互要点(与圆桌 Step2 一致)
- 输入「任务名称 + 简介」→「开始分析」→ 各智能体**并行**输出,运行期间**禁止写文件**(产出走正文)。
- 主区每次只显示**一个**席位的消息列表;**点席位头像切换**要看哪个(单选)。
- 顶部席位状态条显示每个智能体的进行中 / 完成 / 出错。
- 操作区右侧有**模型切换**下拉(拉取后端 `/api/models`);选「默认」则用 `model` prop / 配置 / 后端回退。
切换作用于**下一次**「开始分析 / 重新分析」,运行中禁用。
- 「保存」存档;「历史」里选回**回显**;「重新分析」清空重跑。
---
## 九、注意事项
- **不会污染宿主全局样式**:库未引入 Tailwind preflight,类名都在 `.parallel-agents` 作用域内。
- **深色自带成色**:面板始终深蓝深色,不随宿主明暗切换(与圆桌 Step2 观感一致)。
- **跨域**:若宿主与后端不同源,后端需允许该来源的 CORS + 带 `credentials`;登录端点公共且 CSRF 豁免,
业务请求用 Bearer 头已绕过 CSRF。
- **agent_id 必须真实**:见 [06-可用智能体清单.md](./06-可用智能体清单.md) 获取可用 id 与自查方法。
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>并行多智能体面板 · Dev</title>
</head>
<body style="margin: 0">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
{
"name": "parallel-agents-vue",
"version": "0.1.0",
"description": "并行多智能体面板 —— Vue3 可嵌入组件(输入任务名+简介,并行调用多个智能体,深色消息列表 + 步骤条 + 保存回显 + 重新分析)",
"type": "module",
"main": "./dist/parallel-agents-vue.umd.cjs",
"module": "./dist/parallel-agents-vue.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/parallel-agents-vue.js",
"require": "./dist/parallel-agents-vue.umd.cjs"
},
"./style.css": "./dist/parallel-agents-vue.css"
},
"files": ["dist"],
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"build:lib": "vite build --mode lib",
"typecheck": "vue-tsc --noEmit",
"preview": "vite preview"
},
"peerDependencies": {
"vue": "^3.4.0"
},
"dependencies": {
"markdown-it": "^14.1.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@types/markdown-it": "^14.1.2",
"@vitejs/plugin-vue": "^5.2.0",
"tailwindcss": "^4.1.0",
"typescript": "^5.6.0",
"vite": "^6.0.0",
"vue": "^3.5.0",
"vue-tsc": "^2.1.10"
}
}
+1389
View File
File diff suppressed because it is too large. Load diff
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
/** Dev demo 调试页:填后端地址 / token / 智能体 id,把面板塞进一个弹窗,验证嵌入与流式。 */
import { reactive, ref } from "vue";
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
import type { AgentInput } from "./types";
import { DEFAULT_BASE_URL } from "./config";
const cfg = reactive({
baseUrl: localStorage.getItem("pa.baseUrl") || DEFAULT_BASE_URL,
username: localStorage.getItem("pa.username") || "demo",
agentsRaw: localStorage.getItem("pa.agents") || "",
});
const open = ref(false);
const agents = ref<AgentInput[]>([]);
function parseAgents(): AgentInput[] {
// 每行一个:`agent_id, 显示名` 或仅 `agent_id`。
return cfg.agentsRaw
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((l) => {
const [id, name] = l.split(",").map((x) => x.trim());
return { agent_id: id, name: name || id };
});
}
function launch() {
localStorage.setItem("pa.baseUrl", cfg.baseUrl);
localStorage.setItem("pa.username", cfg.username);
localStorage.setItem("pa.agents", cfg.agentsRaw);
agents.value = parseAgents();
open.value = true;
}
</script>
<template>
<div style="min-height: 100vh; background: #0a1020; color: #cbd5e1; padding: 24px; font-family: system-ui">
<h1 style="font-size: 18px; font-weight: 700; margin-bottom: 12px">并行多智能体面板 · Dev Demo</h1>
<div style="display: flex; flex-direction: column; gap: 8px; max-width: 640px">
<label>后端 baseUrl
<input v-model="cfg.baseUrl" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
</label>
<label>用户名(免密自动登录,由你自己传)
<input v-model="cfg.username" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
</label>
<label>智能体(每行一个:agent_id, 显示名)
<textarea v-model="cfg.agentsRaw" rows="4" placeholder="roundtable-intelligence, 情报收集&#10;roundtable-risk-review, 风险审查" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
</label>
<button @click="launch" style="align-self: flex-start; padding: 8px 16px; background: #1677ff; color: #fff; border: 0; border-radius: 6px; cursor: pointer">
打开面板(弹窗内)
</button>
</div>
<!-- 模拟宿主弹窗:面板自适应容器 -->
<div
v-if="open"
style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 50"
@click.self="open = false"
>
<div style="width: 92vw; max-width: 1080px; height: 86vh; border-radius: 12px; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.5)">
<ParallelAgentsPanel
:base-url="cfg.baseUrl"
:username="cfg.username || undefined"
:agents="agents"
/>
</div>
</div>
</div>
</template>
+236
View File
@@ -0,0 +1,236 @@
/**
* 后端访问层:鉴权(宿主传入 token)+ 并行扇出 SSE + 任务 CRUD。
*
* 全部打到 DeerFlow 网关(宿主传入 `baseUrl`,如 http://localhost:8001):
* - `POST {baseUrl}/api/parallel-agents/run/stream` —— 并行扇出,合并 SSE;
* - `{baseUrl}/api/parallel-agent-tasks` —— 任务保存/回显 CRUD。
*/
import type { AgentInput, Dialogue, SavedTask, StreamEvent } from "./types";
import { USERNAME_LOGIN_PATH } from "./config";
export interface ApiConfig {
baseUrl: string;
/** 已有登录态 token(Bearer)。可传字符串或一个(异步)取 token 的函数。 */
token?: string;
getToken?: () => string | Promise<string>;
/**
* 免密自动登录用户名:不传 token 时,组件用它走「用户名登录」(公共端点)换取 token
* 并缓存。用户名由宿主自己传。
*/
username?: string;
/** 仅当后端 `auth_login.username_login.require_password=true` 时需要的口令。 */
loginPassword?: string;
}
function base(cfg: ApiConfig): string {
return cfg.baseUrl.replace(/\/$/, "");
}
// 免密登录拿到的 token 按 baseUrl::username 缓存,避免每个请求都登一次。
const _tokenCache = new Map<string, string>();
const cacheKey = (cfg: ApiConfig) => `${base(cfg)}::${cfg.username ?? ""}`;
/** 用 username 走公共「用户名登录」端点换 access_token(CSRF 豁免)。 */
async function loginByUsername(cfg: ApiConfig): Promise<string> {
if (!cfg.username) throw new Error("缺少 token 且未提供 username,无法登录");
let url = `${base(cfg)}${USERNAME_LOGIN_PATH}`;
if (cfg.loginPassword) url += `?password=${encodeURIComponent(cfg.loginPassword)}`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: cfg.username }),
credentials: "include",
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`username login failed (${res.status}): ${text}`);
}
const data = (await res.json()) as { access_token: string; token_type?: string };
const token = data.access_token;
_tokenCache.set(cacheKey(cfg), token);
return token;
}
/** token 解析优先级:显式 token > getToken > 缓存 > 用 username 免密登录。 */
async function resolveToken(cfg: ApiConfig): Promise<string | undefined> {
if (cfg.token) return cfg.token;
if (cfg.getToken) return await cfg.getToken();
const cached = _tokenCache.get(cacheKey(cfg));
if (cached) return cached;
if (cfg.username) return await loginByUsername(cfg);
return undefined;
}
/** 清掉缓存 token(401 时强制下次重登)。 */
function invalidateToken(cfg: ApiConfig): void {
_tokenCache.delete(cacheKey(cfg));
}
async function authHeaders(cfg: ApiConfig, json = true): Promise<Record<string, string>> {
const h: Record<string, string> = {};
if (json) h["Content-Type"] = "application/json";
const token = await resolveToken(cfg);
if (token) h["Authorization"] = token.startsWith("Bearer ") ? token : `Bearer ${token}`;
return h;
}
/** 带鉴权的 fetch;若 401 且用 username 免密登录,则清缓存重登并重试一次。 */
async function authedFetch(
cfg: ApiConfig,
url: string,
init: RequestInit & { json?: boolean } = {},
): Promise<Response> {
const { json = true, headers, ...rest } = init;
const run = async () =>
fetch(url, {
...rest,
headers: { ...(await authHeaders(cfg, json)), ...(headers as Record<string, string> | undefined) },
credentials: "include",
});
let res = await run();
if (res.status === 401 && !cfg.token && !cfg.getToken && cfg.username) {
invalidateToken(cfg);
res = await run();
}
return res;
}
// ── 并行扇出 SSE ──────────────────────────────────────────────────────────────
export interface StreamHandlers {
onEvent: (ev: StreamEvent) => void;
onError?: (err: unknown) => void;
}
/**
* 打开 `/api/parallel-agents/run/stream` 一条合并 SSE,逐条把统一事件回调出去。
* 返回的 Promise 在流结束(all_done / 连接关闭)后 resolve。用 `signal` 取消。
*/
export async function streamParallelAgents(
cfg: ApiConfig,
body: { task_name: string; brief: string; agent_ids: string[]; model?: string },
handlers: StreamHandlers,
signal?: AbortSignal,
): Promise<void> {
const res = await authedFetch(cfg, `${base(cfg)}/api/parallel-agents/run/stream`, {
method: "POST",
body: JSON.stringify(body),
signal,
});
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
throw new Error(`run/stream failed (${res.status}): ${text}`);
}
const reader = res.body.getReader();
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");
buffer = lines.pop() ?? "";
for (const raw of lines) {
const line = raw.trim();
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
try {
handlers.onEvent(JSON.parse(payload) as StreamEvent);
} catch {
/* 忽略坏帧 */
}
}
}
} catch (err) {
if ((err as { name?: string })?.name !== "AbortError") handlers.onError?.(err);
} finally {
reader.releaseLock();
}
}
// ── 任务 CRUD(保存 / 回显)─────────────────────────────────────────────────
interface ServerTask {
id: string;
title: string;
brief: string | null;
agents: AgentInput[];
messages?: Dialogue[];
created_at: string | null;
updated_at: string | null;
}
const TASKS = (cfg: ApiConfig) => `${base(cfg)}/api/parallel-agent-tasks`;
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`${ctx} failed (${res.status}): ${text}`);
}
return (await res.json()) as T;
}
export async function listTasks(cfg: ApiConfig): Promise<SavedTask[]> {
const res = await authedFetch(cfg, TASKS(cfg), { json: false });
const data = await readJson<{ tasks: ServerTask[] }>(res, "list tasks");
return data.tasks ?? [];
}
export async function getTask(cfg: ApiConfig, id: string): Promise<SavedTask | null> {
const res = await authedFetch(cfg, `${TASKS(cfg)}/${encodeURIComponent(id)}`, { json: false });
if (res.status === 404) return null;
return readJson<SavedTask>(res, "get task");
}
export async function createTask(
cfg: ApiConfig,
payload: { id?: string; title: string; brief?: string; agents: AgentInput[]; messages: Dialogue[] },
): Promise<SavedTask> {
const res = await authedFetch(cfg, TASKS(cfg), { method: "POST", body: JSON.stringify(payload) });
return readJson<SavedTask>(res, "create task");
}
export async function updateTask(
cfg: ApiConfig,
id: string,
patch: { title?: string; brief?: string; agents?: AgentInput[]; messages?: Dialogue[] },
): Promise<SavedTask | null> {
const res = await authedFetch(cfg, `${TASKS(cfg)}/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(patch),
});
if (res.status === 404) return null;
return readJson<SavedTask>(res, "update task");
}
// ── 模型列表 ──────────────────────────────────────────────────────────────
export interface ModelItem {
name: string;
display_name?: string;
}
/** 列出后端可用模型(GET /api/models)。失败返回空数组(不阻断主流程)。 */
export async function listModels(cfg: ApiConfig): Promise<ModelItem[]> {
try {
const res = await authedFetch(cfg, `${base(cfg)}/api/models`, { json: false });
if (!res.ok) return [];
const data = (await res.json()) as { models?: ModelItem[] };
return data.models ?? [];
} catch {
return [];
}
}
export async function deleteTask(cfg: ApiConfig, id: string): Promise<void> {
const res = await authedFetch(cfg, `${TASKS(cfg)}/${encodeURIComponent(id)}`, {
method: "DELETE",
json: false,
});
if (!res.ok && res.status !== 404) {
const text = await res.text().catch(() => "");
throw new Error(`delete task failed (${res.status}): ${text}`);
}
}
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed } from "vue";
import { avatarColor, isHuman } from "@/lib/avatar-constants";
const props = withDefaults(defineProps<{ type: string; size?: number }>(), { size: 36 });
const color = computed(() => avatarColor(props.type));
const human = computed(() => isHuman(props.type));
</script>
<template>
<span
class="inline-flex shrink-0 items-center justify-center rounded-xl"
:style="{ width: `${size}px`, height: `${size}px`, backgroundColor: color.bg }"
>
<!-- 真人 -->
<svg
v-if="human"
:width="size * 0.58"
:height="size * 0.58"
viewBox="0 0 24 24"
fill="none"
:stroke="color.fg"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="8" r="4" />
<path d="M4 21c0-4 4-6 8-6s8 2 8 6" />
</svg>
<!-- 机器人 -->
<svg
v-else
:width="size * 0.6"
:height="size * 0.6"
viewBox="0 0 24 24"
fill="none"
:stroke="color.fg"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="4" y="8" width="16" height="11" rx="3" />
<path d="M12 8V4" />
<circle cx="12" cy="3" r="1" :fill="color.fg" />
<circle cx="9" cy="13" r="1.2" :fill="color.fg" stroke="none" />
<circle cx="15" cy="13" r="1.2" :fill="color.fg" stroke="none" />
<path d="M9 16.5h6" />
</svg>
</span>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { computed } from "vue";
import MarkdownIt from "markdown-it";
const props = defineProps<{ source: string }>();
const md = new MarkdownIt({ html: false, linkify: true, breaks: true });
const html = computed(() => md.render(props.source ?? ""));
</script>
<template>
<div class="pa-md" v-html="html" />
</template>
@@ -0,0 +1,50 @@
<script setup lang="ts">
/** 单条消息气泡(对齐 Step2 MessageBubble + Step2Message 结构):头像 / 发言人 / 状态徽标 /
* 时间 / 步骤条 / Markdown 正文 / 流式光标 / loading 占位。 */
import { computed } from "vue";
import type { Dialogue } from "@/types";
import Avatar from "./Avatar.vue";
import MessageStepsCard from "./MessageStepsCard.vue";
import MarkdownContent from "./MarkdownContent.vue";
const props = defineProps<{ msg: Dialogue }>();
const hasContent = computed(() => (props.msg.content ?? "").trim().length > 0);
</script>
<template>
<div class="flex items-start gap-3">
<Avatar :type="msg.avatarType" :size="36" />
<div class="min-w-0 flex-1 space-y-1">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<span class="text-sm font-extrabold pa-text">{{ msg.sender }}</span>
<span v-if="msg.confidence" class="pa-badge">{{ msg.confidence }}</span>
</div>
<span class="shrink-0 font-mono text-[11px] pa-text-muted">{{ msg.time }}</span>
</div>
<MessageStepsCard
v-if="msg.steps && msg.steps.length"
:steps="msg.steps"
:streaming="msg.streaming"
/>
<div class="pa-text break-words">
<div
v-if="msg.streaming && !hasContent"
class="flex items-center gap-2 py-1 text-sm pa-text-muted"
>
<span class="pa-spin inline-block">◜</span>
<span>{{ msg.confidence || "正在生成…" }}</span>
<span class="inline-flex items-center gap-0.5">
<span class="pa-bounce inline-block size-1 rounded-full" style="background: var(--pa-primary)" />
<span class="pa-bounce inline-block size-1 rounded-full" style="background: var(--pa-primary); animation-delay: 0.15s" />
<span class="pa-bounce inline-block size-1 rounded-full" style="background: var(--pa-primary); animation-delay: 0.3s" />
</span>
</div>
<MarkdownContent v-if="hasContent" :source="msg.content" />
<span v-if="msg.streaming && hasContent" class="pa-cursor" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,52 @@
<script setup lang="ts">
/** 步骤条 / 思维链卡:折叠展示该气泡运行期间的工具调用步骤(对齐 Step2 MessageStepsCard)。 */
import { ref } from "vue";
import type { DialogueStep } from "@/types";
defineProps<{ steps: DialogueStep[]; streaming?: boolean }>();
const open = ref(true);
function argSummary(args?: Record<string, unknown> | null): string {
if (!args) return "";
const path = (args as { path?: unknown }).path;
if (typeof path === "string") return path;
try {
const s = JSON.stringify(args);
return s.length > 60 ? s.slice(0, 60) + "…" : s;
} catch {
return "";
}
}
</script>
<template>
<div class="pa-surface pa-border mb-2 rounded-lg border p-0.5">
<button
type="button"
class="flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-[11px] font-semibold pa-text-muted"
@click="open = !open"
>
<span
class="inline-block transition-transform"
:class="open ? 'rotate-90' : ''"
>▶</span
>
<span>执行步骤({{ steps.length }})</span>
<span v-if="streaming" class="pa-spin inline-block">◜</span>
</button>
<ol v-show="open" class="space-y-0.5 px-2 pb-1.5 pt-0.5">
<li
v-for="s in steps"
:key="s.id"
class="flex items-start gap-2 text-[11px] pa-text"
>
<span class="mt-1 inline-block size-1.5 shrink-0 rounded-full" style="background: var(--pa-primary)" />
<span class="min-w-0">
<span class="font-semibold">{{ s.label }}</span>
<span v-if="argSummary(s.args)" class="pa-text-muted"> · {{ argSummary(s.args) }}</span>
<span class="pa-text-muted"> · {{ s.ts }}</span>
</span>
</li>
</ol>
</div>
</template>
@@ -0,0 +1,329 @@
<script setup lang="ts">
/**
* 并行多智能体面板(主组件,可嵌入)。
*
* 输入「任务名称 + 简介」,并行调用宿主传入的多个智能体(后端固定禁写文件),消息列表
* 照搬圆桌 Step2 并行多席位的深色样式与结构(步骤条 / 并行双列 / 席位状态条),支持保存
* 聊天记录 + 回显 + 重新分析。
*
* 自适应容器:根元素 `h-full w-full flex flex-col min-h-0`,宿主直接塞进抽屉 / 弹窗即可。
*/
import { computed, onMounted, reactive, ref, watch } from "vue";
import type { AgentInput, Dialogue, SavedTask } from "@/types";
import { useParallelRun } from "@/composables/useParallelRun";
import { createTask, getTask, listModels, listTasks, updateTask, type ApiConfig, type ModelItem } from "@/api";
import { DEFAULT_BASE_URL, DEFAULT_MODEL } from "@/config";
import ParallelSeatStrip from "./ParallelSeatStrip.vue";
import MessageBubble from "./MessageBubble.vue";
const props = withDefaults(
defineProps<{
/** 后端地址;不传用配置文件默认(config.ts 的 DEFAULT_BASE_URL)。 */
baseUrl?: string;
/** 已有 token(Bearer),与 username 二选一;都不传走配置默认地址 + 免密登录失败提示。 */
token?: string;
getToken?: () => string | Promise<string>;
/** 免密自动登录用户名(由宿主自己传)。不传 token 时用它登录换 token。 */
username?: string;
/** 仅当后端开启口令门时需要。 */
loginPassword?: string;
agents: AgentInput[];
taskId?: string;
model?: string;
dark?: boolean;
}>(),
{ dark: true },
);
const emit = defineEmits<{
(e: "saved", task: SavedTask): void;
(e: "update:taskId", id: string): void;
}>();
const cfg = computed<ApiConfig>(() => ({
baseUrl: props.baseUrl || DEFAULT_BASE_URL,
token: props.token,
getToken: props.getToken,
username: props.username,
loginPassword: props.loginPassword,
}));
const run = useParallelRun();
const { dialogues, seats, running } = run;
const form = reactive({ taskName: "", brief: "" });
const currentTaskId = ref<string | null>(props.taskId ?? null);
const selected = ref<string[]>([]);
const saving = ref(false);
const history = ref<SavedTask[]>([]);
const showHistory = ref(false);
const errorMsg = ref<string | null>(null);
// 模型切换:拉后端模型列表,空字符串 = 用默认(prop/config/后端回退)。
const models = ref<ModelItem[]>([]);
const selectedModel = ref<string>(props.model ?? DEFAULT_MODEL ?? "");
const dialogueById = computed(() => {
const m = new Map<number, Dialogue>();
for (const d of dialogues.value) m.set(d.id, d);
return m;
});
const hasResults = computed(() => dialogues.value.length > 0);
const canStart = computed(
() => !running.value && props.agents.length > 0 && (form.taskName.trim() || form.brief.trim()),
);
// 单选:主区每次只显示**一个**席位的消息列表,点头像切换。
watch(
seats,
(s) => {
if (!s.length) {
selected.value = [];
return;
}
// 保留仍存在的当前选中;首次 / 失效时默认显示第 1 个席位。
const cur = selected.value[0];
const keep = cur && s.some((x) => x.agentId === cur) ? cur : s[0].agentId;
selected.value = [keep];
},
{ immediate: true },
);
/** 点头像切换显示:单选,始终切到点击的那个席位。 */
function toggleSeat(agentId: string) {
selected.value = [agentId];
}
const visibleSeats = computed(() => {
const id = selected.value[0] ?? seats.value[0]?.agentId;
const seat = seats.value.find((s) => s.agentId === id);
return seat ? [seat] : [];
});
async function onStart() {
if (!canStart.value) return;
errorMsg.value = null;
try {
await run.start(cfg.value, {
taskName: form.taskName,
brief: form.brief,
agents: props.agents,
model: selectedModel.value || undefined,
});
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
}
}
async function onSave() {
if (saving.value || !hasResults.value) return;
saving.value = true;
errorMsg.value = null;
try {
const title = form.taskName.trim() || "未命名任务";
const agentsSnapshot: AgentInput[] = props.agents.map((a) => ({ ...a }));
let saved: SavedTask | null;
if (currentTaskId.value) {
saved = await updateTask(cfg.value, currentTaskId.value, {
title,
brief: form.brief,
agents: agentsSnapshot,
messages: dialogues.value,
});
} else {
saved = await createTask(cfg.value, {
title,
brief: form.brief,
agents: agentsSnapshot,
messages: dialogues.value,
});
}
if (saved) {
currentTaskId.value = saved.id;
emit("update:taskId", saved.id);
emit("saved", saved);
await refreshHistory();
}
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
saving.value = false;
}
}
async function refreshHistory() {
try {
history.value = await listTasks(cfg.value);
} catch {
/* 历史拉取失败不阻断主流程 */
}
}
async function loadTask(id: string) {
showHistory.value = false;
errorMsg.value = null;
try {
const task = await getTask(cfg.value, id);
if (!task) return;
currentTaskId.value = task.id;
form.taskName = task.title ?? "";
form.brief = task.brief ?? "";
selected.value = [];
run.hydrate(task.messages ?? []);
emit("update:taskId", task.id);
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
}
}
async function refreshModels() {
const list = await listModels(cfg.value);
models.value = list;
// 当前选择不在列表里(且非空)时,回退为默认(空 = 后端回退)。
if (selectedModel.value && !list.some((m) => m.name === selectedModel.value)) {
selectedModel.value = "";
}
}
onMounted(() => {
void refreshHistory();
void refreshModels();
if (props.taskId) void loadTask(props.taskId);
});
</script>
<template>
<div
class="parallel-agents pa-bg flex h-full w-full min-h-0 flex-col"
:class="dark ? 'parallel-agents--dark' : ''"
>
<!-- 顶部:任务名 + 操作 -->
<div class="pa-border flex shrink-0 flex-col gap-2 border-b px-4 py-3">
<div class="flex items-center gap-2">
<input
v-model="form.taskName"
class="pa-input min-w-0 flex-1 px-3 py-1.5 text-sm"
placeholder="任务名称"
/>
<div class="relative shrink-0">
<button
type="button"
class="pa-btn-ghost px-2.5 py-1.5 text-xs"
@click="showHistory = !showHistory"
>
历史 ▾
</button>
<div
v-if="showHistory"
class="pa-card pa-border absolute right-0 z-20 mt-1 max-h-72 w-72 overflow-auto rounded-lg border p-1 shadow-lg"
>
<div v-if="!history.length" class="px-3 py-4 text-center text-xs pa-text-muted">
暂无保存的任务
</div>
<button
v-for="t in history"
:key="t.id"
type="button"
class="pa-btn-ghost block w-full truncate px-2.5 py-1.5 text-left text-xs"
style="border: 0"
@click="loadTask(t.id)"
>
<span class="font-semibold">{{ t.title || "未命名任务" }}</span>
<span class="pa-text-muted"> · {{ (t.updated_at || "").slice(0, 16).replace("T", " ") }}</span>
</button>
</div>
</div>
</div>
<textarea
v-model="form.brief"
rows="2"
class="pa-input w-full resize-y px-3 py-1.5 text-sm"
placeholder="任务简介:把背景、目标、约束写清楚,会并行下发给各智能体"
/>
<div class="flex flex-wrap items-center gap-2">
<button
v-if="!running"
type="button"
class="pa-btn-primary px-3.5 py-1.5 text-sm font-semibold"
:disabled="!canStart"
@click="onStart"
>
{{ hasResults ? "重新分析" : "开始分析" }}
</button>
<button
v-else
type="button"
class="pa-btn-ghost px-3.5 py-1.5 text-sm font-semibold"
@click="run.cancel()"
>
停止
</button>
<button
type="button"
class="pa-btn-ghost px-3.5 py-1.5 text-sm font-semibold"
:disabled="saving || !hasResults"
@click="onSave"
>
{{ saving ? "保存中…" : currentTaskId ? "更新保存" : "保存" }}
</button>
<!-- 模型切换:作用于下一次「开始分析 / 重新分析」 -->
<label class="ml-auto flex items-center gap-1.5 text-xs pa-text-muted">
模型
<select
v-model="selectedModel"
:disabled="running"
class="pa-input px-2 py-1 text-xs"
title="选择本次并行分析使用的模型"
>
<option value="">默认</option>
<option v-for="m in models" :key="m.name" :value="m.name">
{{ m.display_name || m.name }}
</option>
</select>
</label>
<span v-if="props.agents.length === 0" class="text-xs pa-text-muted">
⚠️ 未传入任何智能体(agents 为空)
</span>
</div>
<p v-if="errorMsg" class="text-xs" style="color: var(--pa-rose)">{{ errorMsg }}</p>
</div>
<!-- 席位状态条 -->
<div v-if="seats.length" class="pa-border shrink-0 border-b px-4">
<ParallelSeatStrip :seats="seats" :selected="selected" @toggle="toggleSeat" />
</div>
<!-- 会话区:并行双列 -->
<div class="min-h-0 flex-1 overflow-y-auto p-4">
<div
v-if="hasResults"
class="grid gap-4"
:class="visibleSeats.length > 1 ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'"
>
<div
v-for="seat in visibleSeats"
:key="seat.agentId"
class="pa-surface-soft pa-border flex min-w-0 flex-col rounded-xl border p-3"
>
<MessageBubble
v-if="dialogueById.get(seat.bubbleId)"
:msg="dialogueById.get(seat.bubbleId)!"
/>
<div v-else class="py-6 text-center text-xs pa-text-muted">
{{ seat.name }} · 等待输出…
</div>
</div>
</div>
<div
v-else
class="flex h-full flex-col items-center justify-center gap-2 text-center"
>
<p class="text-sm pa-text-muted">填写任务名称与简介,点「开始分析」并行调用各智能体。</p>
<p class="text-xs pa-text-muted">并行运行期间禁止写文件,产出直接呈现在正文。</p>
</div>
</div>
</div>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
/** 并行席位状态条(对齐 Step2 ParallelSeatStrip):席位头像 + running/done/error 角标,
* 点击可切换主区显示哪两列。 */
import { computed } from "vue";
import type { SeatView } from "@/types";
import Avatar from "./Avatar.vue";
const props = defineProps<{ seats: SeatView[]; selected: string[] }>();
const emit = defineEmits<{ (e: "toggle", agentId: string): void }>();
const runningCount = computed(() => props.seats.filter((s) => s.status === "running").length);
</script>
<template>
<div class="flex items-center gap-2 overflow-x-auto py-1">
<span class="shrink-0 text-[11px] font-bold pa-text-muted">
并行 {{ seats.length }} 席
<span v-if="runningCount > 0" class="pa-text-primary">({{ runningCount }} 进行中)</span>
<span v-if="seats.length > 1" class="font-normal pa-text-muted">· 点头像切换显示</span>
</span>
<div class="flex items-center gap-2">
<button
v-for="seat in seats"
:key="seat.agentId"
type="button"
class="relative shrink-0 rounded-full transition"
:style="
selected.includes(seat.agentId)
? 'box-shadow: 0 0 0 2px var(--pa-primary)'
: ''
"
:title="seat.name"
@click="emit('toggle', seat.agentId)"
>
<span
v-if="seat.status === 'running'"
class="pa-ping absolute inset-0 rounded-full"
style="background: rgba(22, 119, 255, 0.3)"
/>
<span class="relative block" :style="!selected.includes(seat.agentId) ? 'opacity:0.8' : ''">
<Avatar :type="seat.avatarType" :size="28" />
</span>
<span
class="absolute -bottom-0.5 -right-0.5 flex size-3.5 items-center justify-center rounded-full text-[8px] font-black"
style="border: 1px solid var(--pa-bg)"
:class="{
'pa-dot-running': seat.status === 'running',
'pa-dot-done': seat.status === 'done',
'pa-dot-error': seat.status === 'error',
}"
:style="seat.status === 'pending' ? 'background: var(--pa-border)' : ''"
>
<span v-if="seat.status === 'running'" class="pa-spin">◜</span>
<span v-else-if="seat.status === 'done'">✓</span>
<span v-else-if="seat.status === 'error'">✕</span>
</span>
</button>
</div>
</div>
</template>
@@ -0,0 +1,212 @@
/**
* 并行运行编排(前端态)。消费后端合并 SSE,按 agent_id 把增量/步骤/收口路由到对应席位
* 气泡,维护并行席位状态条;带轻量节流(rAF 合并增量),支持取消、回显 hydrate、重置。
*/
import { ref, shallowRef } from "vue";
import type { AgentInput, Dialogue, SeatView, StreamEvent } from "@/types";
import { getAvatarTypeFor } from "@/lib/avatar-constants";
import { streamParallelAgents, type ApiConfig } from "@/api";
function nowTime(): string {
const d = new Date();
const p = (n: number) => String(n).padStart(2, "0");
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
let _seq = 1;
function nextId(): number {
return _seq++;
}
export function useParallelRun() {
const dialogues = ref<Dialogue[]>([]);
const seats = ref<SeatView[]>([]);
const running = ref(false);
const abortRef = shallowRef<AbortController | null>(null);
// 增量节流:累积到 pending,rAF 合并刷新一次,避免逐 token reflow。
const pending = new Map<string, string>(); // agentId -> 待追加文本
let rafScheduled = false;
const bubbleByAgent = new Map<string, number>(); // agentId -> dialogue.id
function flushPending() {
rafScheduled = false;
if (pending.size === 0) return;
for (const [agentId, text] of pending) {
const bid = bubbleByAgent.get(agentId);
const d = dialogues.value.find((x) => x.id === bid);
if (d) d.content += text;
}
pending.clear();
// 触发数组响应(对象内字段已变更,这里强制一次浅刷新以更新双列视图)。
dialogues.value = [...dialogues.value];
}
function scheduleFlush() {
if (rafScheduled) return;
rafScheduled = true;
requestAnimationFrame(flushPending);
}
function setSeatStatus(agentId: string, status: SeatView["status"]) {
const s = seats.value.find((x) => x.agentId === agentId);
if (s) s.status = status;
seats.value = [...seats.value];
}
function findDialogue(agentId: string): Dialogue | undefined {
const bid = bubbleByAgent.get(agentId);
return dialogues.value.find((x) => x.id === bid);
}
function handleEvent(ev: StreamEvent) {
switch (ev.type) {
case "agent_start":
setSeatStatus(ev.agent_id, "running");
break;
case "delta":
pending.set(ev.agent_id, (pending.get(ev.agent_id) ?? "") + ev.text);
scheduleFlush();
break;
case "step": {
const d = findDialogue(ev.agent_id);
if (d) {
d.steps = d.steps ?? [];
d.steps.push({
id: `${d.id}-${d.steps.length}`,
key: ev.key,
label: ev.label,
ts: nowTime(),
args: ev.args ?? null,
});
dialogues.value = [...dialogues.value];
}
break;
}
case "agent_done": {
flushPending();
const d = findDialogue(ev.agent_id);
if (d) {
if (ev.content) d.content = ev.content; // 终态正文以后端为准
d.streaming = false;
d.confidence = "已完成";
}
setSeatStatus(ev.agent_id, "done");
dialogues.value = [...dialogues.value];
break;
}
case "agent_error": {
flushPending();
const d = findDialogue(ev.agent_id);
if (d) {
d.content = d.content || `⚠️ 该智能体执行出错:${ev.message}`;
d.streaming = false;
d.confidence = "出错";
}
setSeatStatus(ev.agent_id, "error");
dialogues.value = [...dialogues.value];
break;
}
case "all_done":
flushPending();
break;
}
}
/** 启动一轮并行运行。会清空旧对话、为每个 agent 建一个流式气泡。 */
async function start(
cfg: ApiConfig,
args: { taskName: string; brief: string; agents: AgentInput[]; model?: string },
): Promise<void> {
if (running.value) return;
reset();
running.value = true;
bubbleByAgent.clear();
const newSeats: SeatView[] = [];
const newDialogues: Dialogue[] = [];
args.agents.forEach((a, i) => {
const avatarType = a.avatarType || getAvatarTypeFor(a.agent_id, i);
const bubbleId = nextId();
bubbleByAgent.set(a.agent_id, bubbleId);
newDialogues.push({
id: bubbleId,
agentId: a.agent_id,
sender: a.name || a.agent_id,
avatarType,
content: "",
time: nowTime(),
confidence: "并行执行中",
streaming: true,
steps: [],
});
newSeats.push({ agentId: a.agent_id, name: a.name || a.agent_id, avatarType, status: "pending", bubbleId });
});
dialogues.value = newDialogues;
seats.value = newSeats;
const ac = new AbortController();
abortRef.value = ac;
try {
await streamParallelAgents(
cfg,
{
task_name: args.taskName,
brief: args.brief,
agent_ids: args.agents.map((a) => a.agent_id),
model: args.model,
},
{ onEvent: handleEvent, onError: (err) => console.error("[parallel-run]", err) },
ac.signal,
);
} finally {
// 收尾:任何仍在 streaming 的气泡都置完成态。
for (const d of dialogues.value) {
if (d.streaming) {
d.streaming = false;
if (d.confidence === "并行执行中") d.confidence = "已完成";
}
}
dialogues.value = [...dialogues.value];
running.value = false;
abortRef.value = null;
}
}
function cancel() {
abortRef.value?.abort();
running.value = false;
}
/** 回显:从保存的对话列表重建(并据此还原席位条为已完成)。 */
function hydrate(saved: Dialogue[]) {
cancel();
bubbleByAgent.clear();
const restored = saved.map((d) => ({ ...d, streaming: false }));
dialogues.value = restored;
const seatMap = new Map<string, SeatView>();
restored.forEach((d) => {
bubbleByAgent.set(d.agentId, d.id);
if (!seatMap.has(d.agentId)) {
seatMap.set(d.agentId, {
agentId: d.agentId,
name: d.sender,
avatarType: d.avatarType,
status: d.confidence === "出错" ? "error" : "done",
bubbleId: d.id,
});
}
});
seats.value = [...seatMap.values()];
}
function reset() {
cancel();
pending.clear();
bubbleByAgent.clear();
dialogues.value = [];
seats.value = [];
}
return { dialogues, seats, running, start, cancel, hydrate, reset };
}
+16
View File
@@ -0,0 +1,16 @@
/**
* 前端配置:后端接口地址等。嵌入方一般直接改这里的默认值即可;也可在 dev 用
* Vite 环境变量覆盖(VITE_PA_BASE_URL)。组件 props 传入时优先级最高。
*/
/** DeerFlow 网关默认地址(接口都打到这里)。 */
export const DEFAULT_BASE_URL: string =
(import.meta as unknown as { env?: Record<string, string> }).env?.VITE_PA_BASE_URL ||
"http://localhost:8001";
/** 可选默认模型名(不填由后端回退到 config.yaml models[0])。 */
export const DEFAULT_MODEL: string | undefined =
(import.meta as unknown as { env?: Record<string, string> }).env?.VITE_PA_MODEL || undefined;
/** 免密「用户名登录」端点(公共 + CSRF 豁免)。 */
export const USERNAME_LOGIN_PATH = "/api/v1/auth/login/username";
+18
View File
@@ -0,0 +1,18 @@
/**
* 组件库入口。其它 Vue 项目:
* import { ParallelAgentsPanel } from "parallel-agents-vue";
* import "parallel-agents-vue/style.css";
* 或全局注册:app.use(ParallelAgentsVue)。
*/
import "./styles/parallel-agents.css";
import type { App } from "vue";
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
export { ParallelAgentsPanel };
export type { AgentInput, Dialogue, SavedTask, SeatView, StreamEvent } from "./types";
export default {
install(app: App) {
app.component("ParallelAgentsPanel", ParallelAgentsPanel);
},
};
@@ -0,0 +1,27 @@
/** 头像配色(对齐圆桌 Step2 的 avatarType:blue/purple/emerald/amber/pink/human)。 */
export const FALLBACK_AVATAR_TYPES = ["blue", "purple", "emerald", "amber", "pink"] as const;
/** 无显式 avatarType 时按下标轮转一个配色(与 Step2 getAvatarTypeFor 行为一致)。 */
export function getAvatarTypeFor(_agentId: string, index: number): string {
return FALLBACK_AVATAR_TYPES[index % FALLBACK_AVATAR_TYPES.length];
}
/** 每种 avatarType 在深色面板里的底色 + 主体色(机器人头 / 真人)。 */
export const AVATAR_COLORS: Record<string, { bg: string; fg: string }> = {
blue: { bg: "rgba(22,119,255,0.18)", fg: "#4096ff" },
purple: { bg: "rgba(124,58,237,0.20)", fg: "#a78bfa" },
emerald: { bg: "rgba(16,185,129,0.20)", fg: "#34d399" },
amber: { bg: "rgba(245,158,11,0.20)", fg: "#fbbf24" },
pink: { bg: "rgba(219,39,119,0.20)", fg: "#f472b6" },
human: { bg: "rgba(148,163,184,0.20)", fg: "#cbd5e1" },
man: { bg: "rgba(148,163,184,0.20)", fg: "#cbd5e1" },
};
export function avatarColor(type: string): { bg: string; fg: string } {
return AVATAR_COLORS[type] ?? AVATAR_COLORS.blue;
}
export function isHuman(type: string): boolean {
return type === "human" || type === "man";
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./styles/parallel-agents.css";
createApp(App).mount("#app");
@@ -0,0 +1,282 @@
/* 并行多智能体面板样式。
*
* 嵌入安全:只引 Tailwind 的 theme + utilities(**不引 preflight**,避免重置宿主全局样式)。
* 颜色用作用域内的 `--pa-*` 深蓝 token(取自《深蓝 UI 规范》ZS-004 / ZS-002,与圆桌 Step2
* 深色一致),全部包在根类 `.parallel-agents` 下,绝不污染宿主。布局用 Tailwind 工具类,
* 主题色用下面的 `.pa-*` 语义类。 */
@layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
/* ── 作用域 token(深色深蓝,自带成色,不依赖宿主主题)─────────────────────── */
.parallel-agents {
--pa-bg: #0c1836; /* 页面背景 zs-004-09 */
--pa-card: #122347; /* 卡片 / 气泡容器 zs-004-08 */
--pa-muted: #16294d; /* 次级表面(步骤卡 / 并行列底) */
--pa-accent: #1a2f5a; /* hover / 软高亮 */
--pa-fg: #c5cfe0; /* 正文 zs-004-02 */
--pa-fg-muted: #6e88b0; /* 次级文字 zs-004-04 */
--pa-border: #2d4473; /* 容器边框 zs-004-06 */
--pa-input: #4a6491; /* 控件描边 */
--pa-primary: #1677ff; /* 主操作 zs-002-06 */
--pa-primary-fg: #ffffff;
--pa-chip-bg: #16335f; /* badge 底 */
--pa-chip-fg: #4096ff; /* badge 文字 */
--pa-amber: #f59e0b;
--pa-emerald: #10b981;
--pa-rose: #ef4444;
color: var(--pa-fg);
background-color: var(--pa-bg);
font-size: 14px;
line-height: 1.6;
box-sizing: border-box;
}
.parallel-agents *,
.parallel-agents *::before,
.parallel-agents *::after {
box-sizing: border-box;
}
/* 滚动条 */
.parallel-agents ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.parallel-agents ::-webkit-scrollbar-thumb {
background: color-mix(in oklch, var(--pa-primary) 40%, var(--pa-border));
border-radius: 999px;
}
.parallel-agents ::-webkit-scrollbar-thumb:hover {
background: color-mix(in oklch, var(--pa-primary) 60%, var(--pa-border));
}
/* ── 语义颜色类(组件用这些上色,布局仍用 Tailwind 工具类)──────────────────── */
.parallel-agents .pa-bg {
background-color: var(--pa-bg);
}
.parallel-agents .pa-card {
background-color: var(--pa-card);
}
.parallel-agents .pa-surface {
background-color: var(--pa-muted);
}
.parallel-agents .pa-surface-soft {
background-color: color-mix(in oklch, var(--pa-muted) 55%, transparent);
}
.parallel-agents .pa-border {
border-color: var(--pa-border);
}
.parallel-agents .pa-text {
color: var(--pa-fg);
}
.parallel-agents .pa-text-muted {
color: var(--pa-fg-muted);
}
.parallel-agents .pa-text-primary {
color: var(--pa-primary);
}
/* 输入框 / 文本域 */
.parallel-agents .pa-input {
background-color: var(--pa-card);
border: 1px solid var(--pa-border);
color: var(--pa-fg);
border-radius: 8px;
}
.parallel-agents .pa-input::placeholder {
color: color-mix(in oklch, var(--pa-fg-muted) 80%, transparent);
}
.parallel-agents .pa-input:focus {
outline: none;
border-color: color-mix(in oklch, var(--pa-primary) 55%, var(--pa-border));
}
/* 主按钮 / 次按钮 */
.parallel-agents .pa-btn-primary {
background-color: var(--pa-primary);
color: var(--pa-primary-fg);
border-radius: 8px;
transition: filter 0.15s ease;
}
.parallel-agents .pa-btn-primary:hover {
filter: brightness(1.08);
}
.parallel-agents .pa-btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.parallel-agents .pa-btn-ghost {
border: 1px solid var(--pa-border);
color: var(--pa-fg);
background-color: transparent;
border-radius: 8px;
transition: background-color 0.15s ease;
}
.parallel-agents .pa-btn-ghost:hover {
background-color: var(--pa-accent);
}
.parallel-agents .pa-btn-ghost:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 状态徽标("实时输出" / "已完成" …)—— 对齐 Step2 confidence chip */
.parallel-agents .pa-badge {
background-color: var(--pa-chip-bg);
color: var(--pa-chip-fg);
border: 1px solid var(--pa-border);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-weight: 800;
text-transform: uppercase;
border-radius: 4px;
padding: 1px 6px;
font-size: 11px;
letter-spacing: 0.02em;
}
/* 席位状态角标色 */
.parallel-agents .pa-dot-running {
background-color: var(--pa-primary);
color: #fff;
}
.parallel-agents .pa-dot-done {
background-color: var(--pa-emerald);
color: #fff;
}
.parallel-agents .pa-dot-error {
background-color: var(--pa-rose);
color: #fff;
}
/* 流式光标 + loading 点 */
.parallel-agents .pa-cursor {
display: inline-block;
width: 4px;
height: 13px;
margin-left: 2px;
vertical-align: middle;
background: var(--pa-primary);
animation: pa-pulse 1s ease-in-out infinite;
}
@keyframes pa-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.25;
}
}
.parallel-agents .pa-bounce {
animation: pa-bounce 0.9s infinite;
}
@keyframes pa-bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
@keyframes pa-spin {
to {
transform: rotate(360deg);
}
}
.parallel-agents .pa-spin {
animation: pa-spin 0.8s linear infinite;
}
@keyframes pa-ping {
75%,
100% {
transform: scale(1.8);
opacity: 0;
}
}
.parallel-agents .pa-ping {
animation: pa-ping 1.2s cubic-bezier(0, 0, 0.2, 1) infinite;
}
/* ── Markdown 正文(对齐 Step2 sharedMarkdownComponents 的深色观感)──────────── */
.parallel-agents .pa-md {
color: var(--pa-fg);
font-size: 14px;
line-height: 1.7;
word-break: break-word;
}
.parallel-agents .pa-md p {
margin: 0.35em 0;
}
.parallel-agents .pa-md h1,
.parallel-agents .pa-md h2,
.parallel-agents .pa-md h3,
.parallel-agents .pa-md h4 {
font-weight: 700;
margin: 0.7em 0 0.35em;
color: var(--pa-fg);
}
.parallel-agents .pa-md h1 {
font-size: 1.3em;
}
.parallel-agents .pa-md h2 {
font-size: 1.18em;
}
.parallel-agents .pa-md h3 {
font-size: 1.06em;
}
.parallel-agents .pa-md ul,
.parallel-agents .pa-md ol {
margin: 0.35em 0;
padding-left: 1.4em;
}
.parallel-agents .pa-md li {
margin: 0.2em 0;
}
.parallel-agents .pa-md a {
color: var(--pa-chip-fg);
text-decoration: underline;
}
.parallel-agents .pa-md code {
background: var(--pa-muted);
border: 1px solid var(--pa-border);
border-radius: 4px;
padding: 0.1em 0.35em;
font-size: 0.9em;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.parallel-agents .pa-md pre {
background: var(--pa-muted);
border: 1px solid var(--pa-border);
border-radius: 8px;
padding: 0.75em;
overflow-x: auto;
margin: 0.5em 0;
}
.parallel-agents .pa-md pre code {
background: transparent;
border: 0;
padding: 0;
}
.parallel-agents .pa-md blockquote {
border-left: 3px solid var(--pa-border);
padding-left: 0.75em;
color: var(--pa-fg-muted);
margin: 0.5em 0;
}
.parallel-agents .pa-md table {
border-collapse: collapse;
margin: 0.5em 0;
width: 100%;
}
.parallel-agents .pa-md th,
.parallel-agents .pa-md td {
border: 1px solid var(--pa-border);
padding: 0.35em 0.6em;
text-align: left;
}
.parallel-agents .pa-md th {
background: var(--pa-muted);
}
+67
View File
@@ -0,0 +1,67 @@
/**
* 并行多智能体面板的共享类型。
*
* `Dialogue` 镜像圆桌 Step2 的 `Step2Dialogue`,外加 `agentId`(并行场景下用来把消息
* 归到对应席位列)。`StreamEvent` 是后端 `/api/parallel-agents/run/stream` 的统一事件协议。
*/
/** 一条工具调用步骤(喂步骤条 / 思维链卡)。 */
export interface DialogueStep {
id: string;
key: string;
label: string;
ts: string;
args?: Record<string, unknown> | null;
}
/** 一条消息气泡(对齐 Step2Dialogue + agentId)。 */
export interface Dialogue {
id: number;
/** 归属席位的 agent_id(并行列定位用)。 */
agentId: string;
sender: string;
avatarType: string;
content: string;
time: string;
/** 状态标签("实时输出" / "已完成" / "出错" 等)。 */
confidence: string;
streaming?: boolean;
steps?: DialogueStep[];
}
/** 并行席位的视图状态(席位状态条用)。 */
export interface SeatView {
agentId: string;
name: string;
avatarType: string;
status: "pending" | "running" | "done" | "error";
/** 关联的 Dialogue.id。 */
bubbleId: number;
}
/** 宿主传入的一个待并行调用的智能体。 */
export interface AgentInput {
agent_id: string;
name: string;
avatarType?: string;
}
/** 后端合并 SSE 的统一事件。 */
export type StreamEvent =
| { type: "agent_start"; agent_id: string }
| { type: "delta"; agent_id: string; text: string }
| { type: "step"; agent_id: string; key: string; label: string; args?: Record<string, unknown> | null }
| { type: "agent_done"; agent_id: string; content: string }
| { type: "agent_error"; agent_id: string; message: string }
| { type: "all_done" };
/** 持久化的任务记录(与后端 /api/parallel-agent-tasks 对应)。 */
export interface SavedTask {
id: string;
title: string;
brief?: string | null;
agents: AgentInput[];
messages?: Dialogue[];
created_at?: string | null;
updated_at?: string | null;
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client"],
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
}
+40
View File
@@ -0,0 +1,40 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
// 两种构建:
// - 默认(`vite build` / `vite dev`):独立 demo 应用(index.html)。
// - `--mode lib`(`pnpm build:lib`):组件库,产出 ESM + UMD + 一个 style.css,
// 供其它 Vue 项目 `import { ParallelAgentsPanel } from "parallel-agents-vue"`。
export default defineConfig(({ mode }) => {
const isLib = mode === "lib";
return {
plugins: [vue(), tailwindcss()],
resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
},
server: { port: 5183 },
build: isLib
? {
lib: {
entry: fileURLToPath(new URL("./src/index.ts", import.meta.url)),
name: "ParallelAgentsVue",
fileName: "parallel-agents-vue",
},
rollupOptions: {
// vue 由宿主提供,不打进库。
external: ["vue"],
output: {
exports: "named",
globals: { vue: "Vue" },
assetFileNames: "parallel-agents-vue.[ext]",
},
},
cssCodeSplit: false,
}
: {
outDir: "dist-app",
},
};
});