feat: 添加MinIO文件系统支持并优化外部连接器功能

- 添加MinIO用户文件系统配置选项(BEAVER_MINIO_ROOT_USER等)
- 更新外部连接器配置结构,包括BASE_URL和认证令牌设置
- 改进connector provider支持更多类型(official, feishu_bot等)
- 实现Mistral模型推理模式支持reasoning_effort参数
- 增强外部连接器策略配置和运行时配置管理
- 添加connector bridge事件验证和安全保护机制
- 优化任务路由逻辑,区分simple_chat和new_task场景
- 更新初始技能工具提示配置,分离authoring admin功能
This commit is contained in:
2026-06-05 11:46:40 +08:00
parent 236ac19789
commit 2c5205b06e
120 changed files with 8321 additions and 1865 deletions

View File

@ -72,6 +72,8 @@ docker build -t beaver/app-instance:latest .
- `--client-secret`
- `--network`
- `--host-bind-ip`
- `--initial-skills-dir`
- `--skip-initial-skills`
- `--build`
- `--replace`
@ -127,6 +129,8 @@ BEAVER_WORKSPACE=/root/.beaver/workspace
所以模型 `provider/api_key/api_base/model` 配一次即可Web / channel 请求不需要、也不应该携带 API Key。
`create-instance.sh` 默认会把仓库根目录的 `skills/` 非覆盖式复制到实例 workspace并把同一个目录只读挂载到实例容器的 `/opt/app/initial-skills``entrypoint.sh` 每次启动都会用该目录补齐缺失的 published 初始 skills已有 skill 目录不会被覆盖index 只做并集追加。
## 当前状态
这层已经支持:

View File

@ -185,6 +185,13 @@ class LiteLLMProvider(LLMProvider):
kwargs["provider"] = provider_payload
def _apply_thinking_mode(self, original_model: str, resolved_model: str, kwargs: dict[str, Any], enabled: bool | None) -> None:
if self._uses_mistral_reasoning_parser(original_model, resolved_model):
if enabled is not None:
extra_body = dict(kwargs.get("extra_body") or {})
extra_body["reasoning_effort"] = "high" if enabled else "none"
kwargs["extra_body"] = extra_body
return
extra_body = dict(kwargs.get("extra_body") or {})
chat_template_kwargs = dict(extra_body.get("chat_template_kwargs") or {})
chat_template_kwargs["enable_thinking"] = False
@ -192,6 +199,12 @@ class LiteLLMProvider(LLMProvider):
extra_body["thinking"] = {"type": "disabled"}
kwargs["extra_body"] = extra_body
def _uses_mistral_reasoning_parser(self, original_model: str, resolved_model: str) -> bool:
if self.provider_name != "vllm":
return False
model_names = f"{original_model} {resolved_model}".lower()
return "mistral" in model_names
async def chat(
self,
messages: list[dict[str, Any]],

View File

@ -8,6 +8,18 @@ from .models import ChannelRuntimeSpec, ValidationResult
from .sidecar_client import ConnectorSidecarClient
from .store import ChannelConnectionStore, CredentialStore
POLICY_CONFIG_KEYS = {
"allowFrom",
"groupAllowFrom",
"requireMentionInGroups",
"respondToMentionAll",
"dmMode",
"maxMessageChars",
"textBatchDelayMs",
"textBatchMaxMessages",
"textBatchMaxChars",
}
class ExternalConnectorBase:
kind = ""
@ -33,6 +45,8 @@ class ExternalConnectorBase:
owner_user_id: str | None,
options: dict[str, Any],
) -> dict[str, Any]:
runtime_config = {"sidecarBaseUrl": self.sidecar_base_url}
runtime_config.update(_policy_runtime_config(options))
connection = self.connection_store.create(
kind=self.kind,
mode="sidecar",
@ -40,7 +54,7 @@ class ExternalConnectorBase:
account_id="",
owner_user_id=owner_user_id,
auth_type="connector_session",
runtime_config={"sidecarBaseUrl": self.sidecar_base_url},
runtime_config=runtime_config,
capabilities=list(self.capabilities),
)
connection = self.connection_store.update_status(connection.connection_id, status="pairing", last_error=None)
@ -54,7 +68,8 @@ class ExternalConnectorBase:
}
view = dict(await self.sidecar_client.start_session(payload))
connection.pairing_session_id = str(view.get("sessionId") or "")
self.connection_store.update(connection)
connection = self.connection_store.update(connection)
connection = self._apply_session_view(connection, view)
view["connectionId"] = connection.connection_id
view["channelId"] = connection.channel_id
return view
@ -62,6 +77,12 @@ class ExternalConnectorBase:
async def poll_session(self, session_id: str) -> dict[str, Any]:
view = dict(await self.sidecar_client.get_session(session_id))
connection = self._connection_for_session(session_id)
connection = self._apply_session_view(connection, view)
view["connectionId"] = connection.connection_id
view["channelId"] = connection.channel_id
return view
def _apply_session_view(self, connection: Any, view: dict[str, Any]) -> Any:
status = str(view.get("status") or "")
if status == "connected":
connection.account_id = str(view.get("accountId") or connection.account_id)
@ -78,9 +99,7 @@ class ExternalConnectorBase:
status="error",
last_error=str(view.get("error") or status),
)
view["connectionId"] = connection.connection_id
view["channelId"] = connection.channel_id
return view
return self.connection_store.get(connection.connection_id)
async def validate(self, connection_id: str) -> ValidationResult:
connection = self.connection_store.get(connection_id)
@ -106,6 +125,7 @@ class ExternalConnectorBase:
config={
"platformKind": self.kind,
"connectionId": connection.connection_id,
**dict(connection.runtime_config),
"sidecarBaseUrl": connection.runtime_config.get("sidecarBaseUrl") or self.sidecar_base_url,
},
secrets_ref=None,
@ -129,3 +149,52 @@ class WeixinConnector(ExternalConnectorBase):
class FeishuConnector(ExternalConnectorBase):
kind = "feishu"
capabilities = ["receive_text", "send_text", "receive_media", "groups"]
def _policy_runtime_config(options: dict[str, Any]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key in POLICY_CONFIG_KEYS:
if key not in options:
continue
value = options[key]
if key in {"allowFrom", "groupAllowFrom"}:
items = _string_list(value)
if items:
result[key] = items
continue
if key in {"maxMessageChars", "textBatchDelayMs", "textBatchMaxMessages", "textBatchMaxChars"}:
number = _positive_int(value)
if number is not None:
result[key] = number
continue
if key in {"requireMentionInGroups", "respondToMentionAll"}:
result[key] = _bool(value)
continue
text = str(value or "").strip()
if text:
result[key] = text
return result
def _string_list(value: Any) -> list[str]:
if isinstance(value, str):
raw_items = value.replace("\n", ",").split(",")
elif isinstance(value, list):
raw_items = value
else:
raw_items = []
return [str(item).strip() for item in raw_items if str(item).strip()]
def _positive_int(value: Any) -> int | None:
try:
number = int(value)
except (TypeError, ValueError):
return None
return number if number > 0 else None
def _bool(value: Any) -> bool:
if isinstance(value, bool):
return value
return str(value).strip().lower() in {"1", "true", "yes", "on"}

View File

@ -431,6 +431,37 @@ def _connection_response_view(connection: Any) -> dict[str, Any]:
return view
def _connector_session_response_view(view: dict[str, Any]) -> dict[str, Any]:
result = dict(view)
metadata = result.get("metadata")
if isinstance(metadata, dict):
result["metadata"] = {
str(key): value
for key, value in metadata.items()
if not _is_sensitive_metadata_key(str(key))
}
return result
def _is_sensitive_metadata_key(key: str) -> bool:
lowered = key.lower()
return any(token in lowered for token in ("secret", "token", "password", "authorization", "credential"))
async def _activate_connected_channel(
request: Request,
registry: ChannelConnectorRegistry,
connection: Any,
) -> Any:
if connection.status != "connected":
return connection
runtime = get_channel_runtime(request)
config = (await registry.materialize_channel_configs()).get(connection.channel_id)
if config is not None:
await runtime.add_channel(connection.channel_id, config)
return registry.connection_store.get(connection.connection_id)
def _normalize_connection_config(config: dict[str, Any] | None) -> dict[str, Any]:
if not isinstance(config, dict):
return {}
@ -441,6 +472,36 @@ def _normalize_connection_config(config: dict[str, Any] | None) -> dict[str, Any
}
def _connector_bridge_guard(connection: Any, payload: WebConnectorBridgeEventRequest) -> None:
if connection.status == "revoked":
raise HTTPException(status_code=404, detail="Channel connection not found")
if connection.status not in {"connected", "running"}:
raise HTTPException(status_code=409, detail="Channel connection is not connected")
mismatches: list[str] = []
if payload.channel_id != connection.channel_id:
mismatches.append("channelId")
if payload.kind != connection.kind:
mismatches.append("kind")
if payload.account_id != connection.account_id:
mismatches.append("accountId")
if mismatches:
raise HTTPException(status_code=403, detail=f"Bridge event does not match connection: {', '.join(mismatches)}")
content = payload.content.strip()
if not content:
raise HTTPException(status_code=400, detail="Bridge event content is required")
max_chars = _positive_int(connection.runtime_config.get("maxMessageChars"), default=20000)
if len(content) > max_chars:
raise HTTPException(status_code=413, detail=f"Bridge event content exceeds maxMessageChars ({max_chars})")
def _positive_int(value: Any, *, default: int) -> int:
try:
number = int(value)
except (TypeError, ValueError):
return default
return number if number > 0 else default
def _camel_to_snake_text(value: str) -> str:
result: list[str] = []
for char in value.strip():
@ -721,8 +782,10 @@ def create_app(
connection_id = _clean_text(view.get("connectionId"))
connection_view = None
if connection_id:
connection_view = _connection_response_view(registry.connection_store.get(connection_id))
return WebConnectorSessionResponse(session=view, connection=connection_view)
connection = registry.connection_store.get(connection_id)
connection = await _activate_connected_channel(request, registry, connection)
connection_view = _connection_response_view(connection)
return WebConnectorSessionResponse(session=_connector_session_response_view(view), connection=connection_view)
@app.get("/api/channel-connector-sessions/{session_id}", response_model=WebConnectorSessionResponse)
async def get_channel_connector_session(session_id: str, request: Request) -> WebConnectorSessionResponse:
@ -739,11 +802,11 @@ def create_app(
raise HTTPException(status_code=400, detail="Connector does not support sessions")
view = await poll_session(session_id)
connection = registry.connection_store.get(connection.connection_id)
if connection.status == "connected":
runtime = get_channel_runtime(request)
config = (await registry.materialize_channel_configs())[connection.channel_id]
await runtime.add_channel(connection.channel_id, config)
return WebConnectorSessionResponse(session=view, connection=_connection_response_view(connection))
connection = await _activate_connected_channel(request, registry, connection)
return WebConnectorSessionResponse(
session=_connector_session_response_view(view),
connection=_connection_response_view(connection),
)
@app.post("/api/channel-connector-bridge/events", response_model=WebConnectorBridgeEventResponse)
async def accept_connector_bridge_event(
@ -760,8 +823,7 @@ def create_app(
connection = registry.connection_store.get(payload.connection_id)
except KeyError:
raise HTTPException(status_code=404, detail="Channel connection not found")
if connection.status == "revoked":
raise HTTPException(status_code=404, detail="Channel connection not found")
_connector_bridge_guard(connection, payload)
store = _message_dedupe_store(_channel_connection_workspace(request))
begin = store.begin(

View File

@ -604,6 +604,8 @@ class AgentService:
if active_task is not None and decision.short_title and not active_task.metadata.get("short_title"):
active_task.metadata["short_title"] = decision.short_title
task_service.store.upsert_task(active_task)
if active_task is not None and (decision.action == "simple_chat" or decision.starts_new_task):
await self._accept_active_task_for_new_topic(active_task)
if active_task is not None and decision.closes_task:
task_service.close_task(active_task.task_id, reason=decision.reason)
return await runner(message, **kwargs)
@ -636,6 +638,20 @@ class AgentService:
)
return await self._run_task_mode(message, runner=runner, kwargs=kwargs, task=task)
async def _accept_active_task_for_new_topic(self, task: TaskRecord) -> None:
"""Accept a completed active Task before routing an unrelated new topic."""
if task.status != "awaiting_acceptance":
return
run_id = next((item for item in reversed(task.run_ids) if item), None)
if not run_id:
return
await self.submit_acceptance(
session_id=task.session_id,
run_id=run_id,
acceptance_type="accept",
)
def _record_revision_acceptance_for_task(
self,
loaded: Any,

View File

@ -32,16 +32,23 @@ When there is an active task, do not force every new user message into that task
- Choose `revise_task` when the user asks to change, correct, refine, expand, reformat, or redo the latest active task result.
- Choose `continue_task` for neutral follow-up questions or additional next steps that still belong to the active task.
- Choose `new_task` when the user asks for clearly unrelated work.
- Choose `simple_chat` for unrelated lightweight conversation. This starts a new topic and the previous task will be accepted automatically.
- Choose `new_task` when the user asks for clearly unrelated work that needs Task capabilities. This starts a new topic and the previous task will be accepted automatically.
- Choose `close_task` when the user says the task is satisfactory or finished, such as "可以了", "就这样", or "that's good".
- Choose `abandon_task` when the user says to stop, cancel, or no longer do the active task.
Do not classify unrelated lightweight conversation as `revise_task` merely because
the active task is awaiting acceptance. A revision must ask to change or correct
the active task result.
Examples with an active weather task:
- "再详细一点" -> `revise_task`
- "加上明后天穿衣建议" -> `revise_task`
- "顺便查一下深圳" -> `continue_task`
- "帮我写一个采购合同" -> `new_task`
- "吃饭没" -> `simple_chat`
- "我在冰岛" -> `simple_chat`
- "可以了" -> `close_task`
- "不用了" -> `abandon_task`

View File

@ -161,6 +161,9 @@ class MainAgentRouter:
"Critical policy:\n"
"- If there is an active Task, choose continue_task or revise_task unless the user's topic is completely unrelated "
"to that Task or the user explicitly closes/abandons it.\n"
"- With an active Task, choose simple_chat for unrelated lightweight conversation and new_task for unrelated work "
"that needs Task capabilities. Either decision starts a new topic.\n"
"- An unrelated lightweight conversation must not be classified as revise_task merely because the active Task is awaiting acceptance.\n"
"- Choose revise_task when the active Task is awaiting feedback or needs revision and the user asks for changes "
"such as '改一下', '加上', '删除', '换成', '再详细点', '格式改成', '不要', or equivalent wording.\n"
"- Choose continue_task for neutral follow-up questions or additional next steps that do not imply dissatisfaction with the previous result.\n"

View File

@ -34,6 +34,21 @@ def _connected_connection(tmp_path):
return connection
def _connection_with_status(tmp_path, status: str):
state_dir = tmp_path / "state" / "channel_connections"
store = ChannelConnectionStore(state_dir / "connections.json")
connection = store.create(
kind="feishu",
mode="sidecar",
display_name="Feishu Main",
account_id="feishu:app-1",
owner_user_id=None,
auth_type="connector_session",
)
store.update_status(connection.connection_id, status=status, last_error=None)
return connection
def _payload(connection, *, event_id: str = "evt-1", delivery_attempt: int = 1) -> dict:
return {
"eventId": event_id,
@ -85,6 +100,77 @@ def test_bridge_endpoint_rejects_invalid_token(tmp_path, monkeypatch) -> None:
service.close()
def test_bridge_endpoint_rejects_connection_identity_mismatch(tmp_path, monkeypatch) -> None:
app, service = _app(tmp_path, monkeypatch)
try:
with TestClient(app) as client:
connection = _connected_connection(tmp_path)
payload = _payload(connection)
payload["channelId"] = "forged-channel"
payload["kind"] = "feishu"
payload["accountId"] = "feishu:attacker"
response = client.post(
"/api/channel-connector-bridge/events",
headers={"Authorization": "Bearer bridge-token"},
json=payload,
)
assert response.status_code == 403
assert "does not match connection" in response.json()["detail"]
finally:
service.close()
def test_bridge_endpoint_rejects_inactive_connection(tmp_path, monkeypatch) -> None:
app, service = _app(tmp_path, monkeypatch)
try:
with TestClient(app) as client:
connection = _connection_with_status(tmp_path, "pairing")
response = client.post(
"/api/channel-connector-bridge/events",
headers={"Authorization": "Bearer bridge-token"},
json={
**_payload(connection),
"kind": "feishu",
"accountId": "feishu:app-1",
},
)
assert response.status_code == 409
assert "not connected" in response.json()["detail"]
finally:
service.close()
def test_bridge_endpoint_rejects_empty_or_oversized_content(tmp_path, monkeypatch) -> None:
app, service = _app(tmp_path, monkeypatch)
try:
with TestClient(app) as client:
connection = _connection_with_status(tmp_path, "connected")
blank = client.post(
"/api/channel-connector-bridge/events",
headers={"Authorization": "Bearer bridge-token"},
json={
**_payload(connection, event_id="blank"),
"kind": "feishu",
"accountId": "feishu:app-1",
"content": " ",
},
)
too_long = client.post(
"/api/channel-connector-bridge/events",
headers={"Authorization": "Bearer bridge-token"},
json={
**_payload(connection, event_id="too-long"),
"kind": "feishu",
"accountId": "feishu:app-1",
"content": "x" * 20001,
},
)
assert blank.status_code == 400
assert too_long.status_code == 413
finally:
service.close()
def test_bridge_endpoint_dedupes_repeated_event(tmp_path, monkeypatch) -> None:
app, service = _app(tmp_path, monkeypatch)
try:

View File

@ -43,6 +43,22 @@ class FakeSidecarClient:
return {"ok": True}
class ImmediateConnectedSidecarClient(FakeSidecarClient):
async def start_session(self, payload: dict) -> dict:
self.started.append(payload)
session = {
"sessionId": "cs_connected",
"kind": payload["kind"],
"status": "connected",
"qrImage": None,
"accountId": f"{payload['kind']}:me",
"displayName": "Connected Account",
"metadata": {"stateRef": "state-1", "appSecret": "secret-1", "tenantAccessToken": "token-1"},
}
self.sessions["cs_connected"] = session
return session
def test_weixin_connector_starts_connector_session(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
@ -67,6 +83,30 @@ def test_weixin_connector_starts_connector_session(tmp_path) -> None:
asyncio.run(run())
def test_feishu_connector_start_session_connected_updates_connection(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
client = ImmediateConnectedSidecarClient()
connector = FeishuConnector(
connection_store=connection_store,
credential_store=credential_store,
sidecar_client=client,
sidecar_base_url="http://external-connector:8787",
)
view = await connector.start_session(display_name="Feishu Main", owner_user_id=None, options={})
connection = connection_store.get(view["connectionId"])
assert view["status"] == "connected"
assert connection.status == "connected"
assert connection.account_id == "feishu:me"
assert connection.display_name == "Connected Account"
assert connection.credentials_ref is not None
asyncio.run(run())
def test_weixin_connector_poll_connected_materializes_external_runtime(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
@ -124,6 +164,67 @@ def test_feishu_connector_uses_feishu_kind(tmp_path) -> None:
asyncio.run(run())
def test_feishu_connector_persists_policy_options_in_runtime_config(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
client = FakeSidecarClient()
connector = FeishuConnector(
connection_store=connection_store,
credential_store=credential_store,
sidecar_client=client,
sidecar_base_url="http://external-connector:8787",
)
await connector.start_session(
display_name="Feishu Main",
owner_user_id=None,
options={
"domain": "feishu",
"requireMentionInGroups": True,
"allowFrom": ["ou_1"],
"groupAllowFrom": ["oc_1"],
"maxMessageChars": 1234,
},
)
connection = connection_store.list()[0]
assert client.started[0]["options"]["requireMentionInGroups"] is True
assert connection.runtime_config["requireMentionInGroups"] is True
assert connection.runtime_config["allowFrom"] == ["ou_1"]
assert connection.runtime_config["groupAllowFrom"] == ["oc_1"]
assert connection.runtime_config["maxMessageChars"] == 1234
asyncio.run(run())
def test_feishu_connector_materializes_policy_for_external_runtime(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
client = ImmediateConnectedSidecarClient()
connector = FeishuConnector(
connection_store=connection_store,
credential_store=credential_store,
sidecar_client=client,
sidecar_base_url="http://external-connector:8787",
)
view = await connector.start_session(
display_name="Feishu Main",
owner_user_id=None,
options={"requireMentionInGroups": True, "allowFrom": ["ou_1"], "groupAllowFrom": ["oc_1"]},
)
spec = await connector.materialize_runtime(view["connectionId"])
assert spec.config["platformKind"] == "feishu"
assert spec.config["requireMentionInGroups"] is True
assert spec.config["allowFrom"] == ["ou_1"]
assert spec.config["groupAllowFrom"] == ["oc_1"]
asyncio.run(run())
def test_connector_session_api_starts_and_polls_connected_session(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token")
config_path = tmp_path / "config.json"
@ -174,3 +275,44 @@ def test_connector_session_api_starts_and_polls_connected_session(tmp_path, monk
assert polled.json()["connection"]["channel_id"] in app.state.channel_runtime.adapters
finally:
service.close()
def test_connector_session_api_activates_immediate_connected_session(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token")
config_path = tmp_path / "config.json"
config_path.write_text(
'{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
client = ImmediateConnectedSidecarClient()
try:
with TestClient(app) as http:
state_dir = tmp_path / "state" / "channel_connections"
connection_store = ChannelConnectionStore(state_dir / "connections.json")
credential_store = CredentialStore(state_dir / "credentials.json")
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
registry.register(
FeishuConnector(
connection_store=connection_store,
credential_store=credential_store,
sidecar_client=client,
sidecar_base_url="http://external-connector:8787",
)
)
app.state.channel_connector_registry = registry
started = http.post(
"/api/channel-connector-sessions",
json={"kind": "feishu", "displayName": "Feishu Main", "options": {}},
)
assert started.status_code == 200
connection = started.json()["connection"]
assert connection["status"] == "connected"
assert connection["channel_id"] in app.state.channel_runtime.adapters
assert started.json()["session"]["metadata"] == {"stateRef": "state-1"}
finally:
service.close()

View File

@ -30,10 +30,14 @@ EXPECTED_INITIAL_SKILL_TOOLS = {
"mcp_outlook_mcp_calendar_find_meeting_times",
"mcp_outlook_mcp_calendar_delta_sync",
],
"skills-admin": ["skills_list", "skill_manage", "skill_view"],
"skills-admin": ["skills_list", "skill_view"],
"terminal-operation": ["terminal", "process", "execute_code"],
"utility-tools": ["clarify", "delegate", "send_message", "spawn", "todo"],
"web-operation": ["web_fetch", "web_search"],
"multi-search-engine": ["web_fetch"],
}
EXPECTED_NON_INITIAL_SKILL_TOOLS = {
"skills-authoring-admin": ["skill_manage"],
}
@ -48,6 +52,23 @@ def test_initial_skill_tool_hints_match_runtime_tool_names() -> None:
assert version["tool_hints"] == expected_tools
def test_skill_authoring_admin_is_seeded_but_not_initial() -> None:
published = json.loads((REPO_ROOT / "skills" / "_index" / "published.json").read_text(encoding="utf-8"))
disabled = json.loads((REPO_ROOT / "skills" / "_index" / "disabled.json").read_text(encoding="utf-8"))
assert "skills-authoring-admin" not in published["items"]
assert "skills-authoring-admin" in disabled["items"]
for skill_name, expected_tools in EXPECTED_NON_INITIAL_SKILL_TOOLS.items():
skill_dir = REPO_ROOT / "skills" / skill_name / "versions" / "v0001"
frontmatter, _body = parse_frontmatter((skill_dir / "SKILL.md").read_text(encoding="utf-8"))
version = json.loads((skill_dir / "version.json").read_text(encoding="utf-8"))
assert frontmatter["tools"] == expected_tools
assert version["frontmatter"]["tools"] == expected_tools
assert version["tool_hints"] == expected_tools
def test_default_runtime_registers_skill_view_tool(tmp_path: Path) -> None:
loaded = EngineLoader(workspace=tmp_path).load()
try:

View File

@ -169,6 +169,90 @@ def test_thinking_mode_is_forced_disabled_even_when_requested_enabled(monkeypatc
}
def test_mistral_vllm_uses_reasoning_effort_instead_of_qwen_thinking_body(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict = {}
class Message:
content = "ok"
reasoning_content = None
tool_calls = []
class Choice:
message = Message()
finish_reason = "stop"
class Response:
choices = [Choice()]
usage = None
async def fake_acompletion(**kwargs):
captured.update(kwargs)
return Response()
monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion)
monkeypatch.setattr("beaver.engine.providers.litellm.litellm", SimpleNamespace())
provider = LiteLLMProvider(
api_key="EMPTY",
api_base="http://localhost:8000/v1",
default_model="mistralai/Mistral-Medium-3.5-128B",
provider_name="vllm",
)
asyncio.run(
provider.chat(
[{"role": "user", "content": "reply ok"}],
model="mistralai/Mistral-Medium-3.5-128B",
thinking_enabled=True,
)
)
assert captured["model"] == "hosted_vllm/mistralai/Mistral-Medium-3.5-128B"
assert captured["extra_body"] == {"reasoning_effort": "high"}
def test_mistral_vllm_omits_reasoning_body_when_thinking_mode_is_unspecified(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict = {}
class Message:
content = "ok"
reasoning_content = None
tool_calls = []
class Choice:
message = Message()
finish_reason = "stop"
class Response:
choices = [Choice()]
usage = None
async def fake_acompletion(**kwargs):
captured.update(kwargs)
return Response()
monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion)
monkeypatch.setattr("beaver.engine.providers.litellm.litellm", SimpleNamespace())
provider = LiteLLMProvider(
api_key="EMPTY",
api_base="http://localhost:8000/v1",
default_model="mistralai/Mistral-Medium-3.5-128B",
provider_name="vllm",
)
asyncio.run(
provider.chat(
[{"role": "user", "content": "reply ok"}],
model="mistralai/Mistral-Medium-3.5-128B",
)
)
assert "extra_body" not in captured
def test_litellm_provider_sanitizes_tool_call_arguments(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict = {}

View File

@ -149,6 +149,22 @@ def test_router_injects_intent_skill_guidance() -> None:
assert "Weather and current external data" in prompt
def test_router_prompt_treats_unrelated_lightweight_conversation_as_new_topic() -> None:
provider = RouterProvider('{"action":"simple_chat","reason":"unrelated lightweight conversation"}')
asyncio.run(
MainAgentRouter().classify(
"吃饭没",
active_task=_task(),
provider=provider,
)
)
prompt = provider.calls[0]["messages"][1]["content"]
assert "unrelated lightweight conversation" in prompt
assert "must not be classified as revise_task merely because the active Task is awaiting acceptance" in prompt
def test_router_closes_active_task_from_llm_decision() -> None:
decision = asyncio.run(
MainAgentRouter().classify(

View File

@ -99,6 +99,191 @@ def test_task_run_records_evidence_and_waits_for_acceptance(tmp_path: Path) -> N
assert "validated" not in event_types
def test_unrelated_simple_chat_auto_accepts_active_task(tmp_path: Path) -> None:
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=StubTaskExecutionPlanner(),
)
)
first = asyncio.run(
service.process_direct(
"recommend food in Hengqin",
session_id="web:new-topic-chat",
provider_bundle=_bundle("Food recommendations"),
)
)
second = asyncio.run(
service.process_direct(
"have you eaten?",
session_id="web:new-topic-chat",
provider_bundle=_bundle("I do not eat.", route_action="simple_chat"),
)
)
task_service = service.create_loop().boot().task_service
assert task_service is not None
previous = task_service.get_task(first.task_id or "")
assert previous is not None
assert previous.status == "closed"
assert previous.run_ids == [first.run_id]
assert previous.feedback[-1]["acceptance_type"] == "accept"
assert previous.metadata["final_accepted_run_id"] == first.run_id
assert second.task_id is None
def test_unrelated_new_task_auto_accepts_previous_task(tmp_path: Path) -> None:
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=StubTaskExecutionPlanner(),
)
)
first = asyncio.run(
service.process_direct(
"recommend food in Hengqin",
session_id="web:new-topic-task",
provider_bundle=_bundle("Food recommendations"),
)
)
second = asyncio.run(
service.process_direct(
"check today's weather in Iceland",
session_id="web:new-topic-task",
provider_bundle=_bundle("Weather result", route_action="new_task"),
)
)
task_service = service.create_loop().boot().task_service
assert task_service is not None
previous = task_service.get_task(first.task_id or "")
current = task_service.get_task(second.task_id or "")
assert previous is not None
assert current is not None
assert previous.status == "closed"
assert previous.run_ids == [first.run_id]
assert previous.feedback[-1]["acceptance_type"] == "accept"
assert current.task_id != previous.task_id
assert current.status == "awaiting_acceptance"
assert current.run_ids == [second.run_id]
def test_related_follow_up_continues_active_task_without_accepting_it(tmp_path: Path) -> None:
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=StubTaskExecutionPlanner(),
)
)
first = asyncio.run(
service.process_direct(
"recommend food in Hengqin",
session_id="web:continue-topic",
provider_bundle=_bundle("Food recommendations"),
)
)
second = asyncio.run(
service.process_direct(
"include restaurants near the port",
session_id="web:continue-topic",
provider_bundle=_bundle("More recommendations", route_action="continue_task"),
)
)
task_service = service.create_loop().boot().task_service
assert task_service is not None
task = task_service.get_task(first.task_id or "")
assert task is not None
assert second.task_id == first.task_id
assert task.status == "awaiting_acceptance"
assert task.run_ids == [first.run_id, second.run_id]
assert task.feedback == []
def test_requested_revision_keeps_active_task_without_accepting_it(tmp_path: Path) -> None:
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=StubTaskExecutionPlanner(),
)
)
first = asyncio.run(
service.process_direct(
"recommend food in Hengqin",
session_id="web:revise-topic",
provider_bundle=_bundle("Food recommendations"),
)
)
second = asyncio.run(
service.process_direct(
"remove expensive restaurants",
session_id="web:revise-topic",
provider_bundle=_bundle("Revised recommendations", route_action="revise_task"),
)
)
task_service = service.create_loop().boot().task_service
assert task_service is not None
task = task_service.get_task(first.task_id or "")
assert task is not None
assert second.task_id == first.task_id
assert task.status == "awaiting_acceptance"
assert task.run_ids == [first.run_id, second.run_id]
assert [item["acceptance_type"] for item in task.feedback] == ["revise"]
def test_router_failure_fallback_does_not_auto_accept_active_task(tmp_path: Path) -> None:
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=StubTaskExecutionPlanner(),
)
)
first = asyncio.run(
service.process_direct(
"recommend food in Hengqin",
session_id="web:router-fallback",
provider_bundle=_bundle("Food recommendations"),
)
)
fallback_bundle = ProviderBundle(
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
main_provider=StubProvider(
[
LLMResponse(
content="Continued response",
finish_reason="stop",
provider_name="stub",
model="stub-model",
)
]
),
auxiliary_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
auxiliary_provider=StubProvider([]),
)
second = asyncio.run(
service.process_direct(
"continue after router failure",
session_id="web:router-fallback",
provider_bundle=fallback_bundle,
)
)
task_service = service.create_loop().boot().task_service
assert task_service is not None
task = task_service.get_task(first.task_id or "")
assert task is not None
assert second.task_id == first.task_id
assert task.status == "awaiting_acceptance"
assert task.run_ids == [first.run_id, second.run_id]
assert task.feedback == []
def test_acceptance_closes_task_and_triggers_learning(tmp_path: Path) -> None:
service = AgentService(
loader=EngineLoader(

View File

@ -19,6 +19,9 @@ AUTHZ_INTERNAL_TOKEN=""
AUTHZ_OUTLOOK_MCP_URL=""
OUTLOOK_MCP_SERVER_ID="${OUTLOOK_MCP_SERVER_ID:-outlook_mcp}"
USER_FILES_MAX_UPLOAD_BYTES="${USER_FILES_MAX_UPLOAD_BYTES:-}"
EXTERNAL_CONNECTOR_BASE_URL="${EXTERNAL_CONNECTOR_BASE_URL:-http://external-connector:8787}"
EXTERNAL_CONNECTOR_TOKEN="${EXTERNAL_CONNECTOR_TOKEN:-}"
BEAVER_BRIDGE_TOKEN="${BEAVER_BRIDGE_TOKEN:-}"
BACKEND_ID=""
CLIENT_ID=""
CLIENT_SECRET=""
@ -40,6 +43,7 @@ REGISTRY_PATH="${REGISTRY_PATH:-$REGISTRY_PATH_DEFAULT}"
NETWORK_NAME="${NETWORK_NAME:-}"
HOST_BIND_IP="${HOST_BIND_IP:-127.0.0.1}"
INITIAL_SKILLS_DIR="${INITIAL_SKILLS_DIR:-${SCRIPT_DIR}/../skills}"
INITIAL_SKILLS_EXCLUDE="${INITIAL_SKILLS_EXCLUDE:-officebench-mcp}"
SEED_INITIAL_SKILLS=1
FORCE_BUILD=0
REPLACE=0
@ -73,6 +77,11 @@ Optional:
Default Outlook MCP server id. Default: outlook_mcp
--user-files-max-upload-bytes <bytes>
Optional max upload size for the user file system.
--external-connector-base-url <url>
External connector sidecar URL. Default: http://external-connector:8787
--external-connector-token <token>
Service token used for Beaver-to-sidecar requests.
--bridge-token <token> Service token accepted from the connector bridge.
--backend-id <id> Pre-assigned backend id.
--client-id <id> Pre-assigned AuthZ client id.
--client-secret <secret> Pre-assigned AuthZ client secret.
@ -299,6 +308,15 @@ data = {
"textBatchDelaySeconds": 0.5,
},
},
"terminal-dev": {
"enabled": True,
"kind": "terminal",
"mode": "websocket",
"accountId": "local",
"displayName": "Terminal Dev",
"config": {},
"secrets": {},
},
},
}
@ -344,7 +362,7 @@ seed_initial_skills() {
fi
mkdir -p "$target_dir"
INITIAL_SKILLS_DIR="$initial_skills_dir" TARGET_DIR="$target_dir" python3 - <<'PY'
INITIAL_SKILLS_DIR="$initial_skills_dir" TARGET_DIR="$target_dir" INITIAL_SKILLS_EXCLUDE="$INITIAL_SKILLS_EXCLUDE" python3 - <<'PY'
import json
import shutil
import os
@ -352,10 +370,13 @@ from pathlib import Path
initial = Path(os.environ["INITIAL_SKILLS_DIR"]).resolve()
target = Path(os.environ["TARGET_DIR"]).resolve()
excluded = {item.strip() for item in os.environ.get("INITIAL_SKILLS_EXCLUDE", "").split(",") if item.strip()}
for child in sorted(initial.iterdir()):
if child.name.startswith("."):
continue
if child.name in excluded:
continue
destination = target / child.name
if destination.exists():
continue
@ -383,6 +404,8 @@ for index_name in ("published", "disabled"):
merged = []
for item in [*target_items, *initial_items]:
text = str(item).strip()
if text in excluded:
continue
if text and text not in merged:
merged.append(text)
target_index.parent.mkdir(parents=True, exist_ok=True)
@ -534,6 +557,18 @@ while [[ $# -gt 0 ]]; do
USER_FILES_MAX_UPLOAD_BYTES="${2:-}"
shift 2
;;
--external-connector-base-url)
EXTERNAL_CONNECTOR_BASE_URL="${2:-}"
shift 2
;;
--external-connector-token)
EXTERNAL_CONNECTOR_TOKEN="${2:-}"
shift 2
;;
--bridge-token)
BEAVER_BRIDGE_TOKEN="${2:-}"
shift 2
;;
--backend-id)
BACKEND_ID="${2:-}"
shift 2
@ -643,6 +678,16 @@ if [[ "$SKIP_PROVIDER_CONFIG" -ne 1 ]]; then
esac
fi
if [[ -n "$INITIAL_SKILLS_DIR" ]]; then
INITIAL_SKILLS_DIR="$(INITIAL_SKILLS_DIR="$INITIAL_SKILLS_DIR" python3 - <<'PY'
import os
from pathlib import Path
print(Path(os.environ["INITIAL_SKILLS_DIR"]).expanduser().resolve())
PY
)"
fi
if [[ -n "$BACKEND_ID$CLIENT_ID$CLIENT_SECRET" ]]; then
[[ -n "$BACKEND_ID" && -n "$CLIENT_ID" && -n "$CLIENT_SECRET" ]] || die "backend identity requires --backend-id, --client-id and --client-secret together"
fi
@ -721,14 +766,29 @@ RUN_ARGS=(
-e "APP_BACKEND_PORT=18080"
-e "BEAVER_ENABLE_SELF_RESTART=1"
-e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}"
-e "EXTERNAL_CONNECTOR_BASE_URL=${EXTERNAL_CONNECTOR_BASE_URL}"
--label "beaver.instance.id=${INSTANCE_ID}"
--label "beaver.instance.slug=${INSTANCE_SLUG}"
--label "beaver.instance.public_url=${PUBLIC_URL}"
)
if [[ "$SEED_INITIAL_SKILLS" -eq 1 && -n "$INITIAL_SKILLS_DIR" ]]; then
RUN_ARGS+=(
-v "${INITIAL_SKILLS_DIR}:/opt/app/initial-skills:ro"
-e "BEAVER_INITIAL_SKILLS_DIR=/opt/app/initial-skills"
-e "BEAVER_INITIAL_SKILLS_EXCLUDE=${INITIAL_SKILLS_EXCLUDE}"
)
fi
if [[ -n "$USER_FILES_MAX_UPLOAD_BYTES" ]]; then
RUN_ARGS+=(-e "BEAVER_USER_FILES_MAX_UPLOAD_BYTES=${USER_FILES_MAX_UPLOAD_BYTES}")
fi
if [[ -n "$EXTERNAL_CONNECTOR_TOKEN" ]]; then
RUN_ARGS+=(-e "EXTERNAL_CONNECTOR_TOKEN=${EXTERNAL_CONNECTOR_TOKEN}")
fi
if [[ -n "$BEAVER_BRIDGE_TOKEN" ]]; then
RUN_ARGS+=(-e "BEAVER_BRIDGE_TOKEN=${BEAVER_BRIDGE_TOKEN}")
fi
if [[ -n "$NETWORK_NAME" ]]; then
RUN_ARGS+=(--network "$NETWORK_NAME")

View File

@ -12,6 +12,8 @@ BEAVER_CONFIG_PATH="${BEAVER_CONFIG_PATH:-$BEAVER_HOME/config.json}"
BEAVER_WORKSPACE="${BEAVER_WORKSPACE:-$BEAVER_HOME/workspace}"
BEAVER_AUTH_FILE="${BEAVER_AUTH_FILE:-$BEAVER_HOME/web_auth_users.json}"
BEAVER_RUNTIME_ENV_FILE="${BEAVER_RUNTIME_ENV_FILE:-$BEAVER_HOME/runtime.env}"
BEAVER_INITIAL_SKILLS_DIR="${BEAVER_INITIAL_SKILLS_DIR:-/opt/app/initial-skills}"
BEAVER_INITIAL_SKILLS_EXCLUDE="${BEAVER_INITIAL_SKILLS_EXCLUDE:-officebench-mcp}"
log() {
printf '[app-instance] %s\n' "$*"
@ -26,6 +28,68 @@ require_file() {
fi
}
seed_initial_skills() {
local initial_skills_dir="$1"
local target_dir="$2"
if [[ ! -d "$initial_skills_dir" ]]; then
return
fi
if [[ ! -f "$initial_skills_dir/_index/published.json" ]]; then
log "initial skills source has no published index, skipping: ${initial_skills_dir}"
return
fi
mkdir -p "$target_dir"
INITIAL_SKILLS_DIR="$initial_skills_dir" TARGET_DIR="$target_dir" INITIAL_SKILLS_EXCLUDE="$BEAVER_INITIAL_SKILLS_EXCLUDE" python - <<'PY'
import json
import os
import shutil
from pathlib import Path
initial = Path(os.environ["INITIAL_SKILLS_DIR"]).resolve()
target = Path(os.environ["TARGET_DIR"]).resolve()
excluded = {item.strip() for item in os.environ.get("INITIAL_SKILLS_EXCLUDE", "").split(",") if item.strip()}
for child in sorted(initial.iterdir()):
if child.name.startswith(".") or child.name in excluded:
continue
destination = target / child.name
if destination.exists():
continue
if child.is_dir():
shutil.copytree(child, destination)
elif child.is_file():
shutil.copy2(child, destination)
for index_name in ("published", "disabled"):
initial_index = initial / "_index" / f"{index_name}.json"
target_index = target / "_index" / f"{index_name}.json"
if not initial_index.exists():
continue
try:
initial_items = json.loads(initial_index.read_text(encoding="utf-8")).get("items", [])
except json.JSONDecodeError:
initial_items = []
if target_index.exists():
try:
target_items = json.loads(target_index.read_text(encoding="utf-8")).get("items", [])
except json.JSONDecodeError:
target_items = []
else:
target_items = []
merged = []
for item in [*target_items, *initial_items]:
text = str(item).strip()
if text in excluded:
continue
if text and text not in merged:
merged.append(text)
target_index.parent.mkdir(parents=True, exist_ok=True)
target_index.write_text(json.dumps({"items": merged}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
PY
}
cleanup() {
local status=$?
@ -54,12 +118,15 @@ if [[ -f "$BEAVER_RUNTIME_ENV_FILE" ]]; then
fi
require_file "$BEAVER_CONFIG_PATH" "Missing Beaver config"
seed_initial_skills "$BEAVER_INITIAL_SKILLS_DIR" "$BEAVER_WORKSPACE/skills"
export BEAVER_AUTH_FILE
export BEAVER_RUNTIME_ENV_FILE
export BEAVER_HOME
export BEAVER_CONFIG_PATH
export BEAVER_WORKSPACE
export BEAVER_INITIAL_SKILLS_DIR
export BEAVER_INITIAL_SKILLS_EXCLUDE
export PORT="$APP_FRONTEND_PORT"
export HOSTNAME="127.0.0.1"
export PYTHONFAULTHANDLER="${PYTHONFAULTHANDLER:-1}"

View File

@ -34,6 +34,7 @@ import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { type AppLocale, pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { containedLongTextClass, containedPreservedLongTextClass } from '@/lib/text-wrapping';
const LOAD_RETRY_DELAYS_MS = [0, 600, 1200];
@ -126,8 +127,8 @@ export default function FilesPage() {
if (selectedFile?.path === item.path) {
setSelectedFile(null);
}
} catch {
// ignore
} catch (err: any) {
setLoadError(err.message || pickAppText(locale, '删除失败', 'Delete failed'));
}
};
@ -147,8 +148,8 @@ export default function FilesPage() {
a.click();
a.remove();
URL.revokeObjectURL(a.href);
} catch {
// ignore
} catch (err: any) {
setPreviewError(err.message || pickAppText(locale, '下载失败', 'Download failed'));
}
};
@ -160,13 +161,13 @@ export default function FilesPage() {
setUploadProgress(0);
try {
for (let i = 0; i < files.length; i++) {
await uploadUserFile(files[i], currentPath || 'uploads', (pct) => {
await uploadUserFile(files[i], currentPath, (pct) => {
setUploadProgress(Math.round((i / files.length) * 100 + pct / files.length));
});
}
await load();
} catch {
// ignore
} catch (err: any) {
setLoadError(err.message || pickAppText(locale, '上传失败', 'Upload failed'));
} finally {
setUploading(false);
setUploadProgress(0);
@ -183,8 +184,8 @@ export default function FilesPage() {
setShowMkdir(false);
setNewDirName('');
await load();
} catch {
// ignore
} catch (err: any) {
setLoadError(err.message || pickAppText(locale, '创建文件夹失败', 'Failed to create folder'));
}
};
@ -213,16 +214,17 @@ export default function FilesPage() {
};
return (
<div className="mx-auto max-w-7xl p-6">
<div className="mx-auto w-full max-w-7xl overflow-x-hidden px-4 py-6 sm:px-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-bold">{pickAppText(locale, '文件管理', 'Files')}</h1>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-11"
onClick={() => setShowMkdir(true)}
disabled={loading || !currentPath}
disabled={loading}
>
<FolderPlus className="w-4 h-4 mr-1" />
{pickAppText(locale, '新建文件夹', 'New folder')}
@ -230,8 +232,9 @@ export default function FilesPage() {
<Button
variant="outline"
size="sm"
className="h-11"
onClick={() => fileInputRef.current?.click()}
disabled={uploading || !currentPath}
disabled={uploading}
>
{uploading ? (
<>
@ -252,7 +255,15 @@ export default function FilesPage() {
className="hidden"
onChange={handleUpload}
/>
<Button variant="outline" size="sm" onClick={() => load()} disabled={loading}>
<Button
variant="outline"
size="icon"
className="h-11 w-11"
onClick={() => load()}
disabled={loading}
aria-label={pickAppText(locale, '刷新', 'Refresh')}
title={pickAppText(locale, '刷新', 'Refresh')}
>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
@ -266,7 +277,7 @@ export default function FilesPage() {
<div className="flex items-center gap-1 mb-4 text-sm text-muted-foreground flex-wrap">
<button
onClick={() => navigateTo('')}
className="flex items-center gap-1 hover:text-foreground transition-colors px-1.5 py-0.5 rounded hover:bg-accent"
className="inline-flex h-11 items-center gap-1 rounded px-2 transition-colors hover:bg-accent hover:text-foreground"
>
<Home className="w-3.5 h-3.5" />
{pickAppText(locale, '文件', 'Files')}
@ -279,7 +290,7 @@ export default function FilesPage() {
<ChevronRight className="w-3 h-3 flex-shrink-0" />
<button
onClick={() => navigateTo(path)}
className={`px-1.5 py-0.5 rounded transition-colors ${
className={`inline-flex h-11 items-center rounded px-2 text-left transition-colors ${containedLongTextClass} ${
isLast
? 'text-foreground font-medium'
: 'hover:text-foreground hover:bg-accent'
@ -294,9 +305,13 @@ export default function FilesPage() {
{/* New directory input */}
{showMkdir && (
<div className="flex items-center gap-2 mb-4">
<div className="mb-4 flex flex-wrap items-center gap-2">
<Folder className="w-4 h-4 text-muted-foreground" />
<label htmlFor="new-folder-name" className="sr-only">
{pickAppText(locale, '文件夹名称', 'Folder name')}
</label>
<input
id="new-folder-name"
ref={mkdirInputRef}
type="text"
value={newDirName}
@ -309,15 +324,16 @@ export default function FilesPage() {
}
}}
placeholder={pickAppText(locale, '文件夹名称', 'Folder name')}
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-ring"
className="h-11 min-w-0 flex-1 rounded-md border border-border bg-background px-3 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
autoFocus
/>
<Button size="sm" onClick={handleCreateDir}>
<Button size="sm" className="h-11" onClick={handleCreateDir}>
{pickAppText(locale, '创建', 'Create')}
</Button>
<Button
size="sm"
variant="ghost"
className="h-11"
onClick={() => {
setShowMkdir(false);
setNewDirName('');
@ -328,9 +344,9 @@ export default function FilesPage() {
</div>
)}
<div className="grid gap-4 lg:grid-cols-[minmax(360px,440px)_minmax(0,1fr)]">
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-4 lg:grid-cols-[minmax(360px,440px)_minmax(0,1fr)]">
{/* File list */}
<div className="min-h-[520px] rounded-lg border border-border bg-card">
<div className="min-w-0 rounded-lg border border-border bg-card lg:min-h-[520px]">
{loading && items.length === 0 ? (
<div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin" />
@ -340,7 +356,7 @@ export default function FilesPage() {
<FolderOpen className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium">{pickAppText(locale, '加载失败', 'Failed to load')}</p>
<p className="max-w-sm text-center text-sm">{loadError}</p>
<Button className="mt-4" variant="outline" size="sm" onClick={() => load()}>
<Button className="mt-4 h-11" variant="outline" size="sm" onClick={() => load()}>
<RefreshCw className="mr-1 h-4 w-4" />
{pickAppText(locale, '重试', 'Retry')}
</Button>
@ -349,90 +365,80 @@ export default function FilesPage() {
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<FolderOpen className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium">{pickAppText(locale, '空文件夹', 'Empty folder')}</p>
<p className="text-sm">{pickAppText(locale, '点击上方"上传"或"新建文件夹"按钮开始使用', 'Use "Upload" or "New folder" above to get started')}</p>
<p className="px-4 text-center text-sm">{pickAppText(locale, '点击上方"上传"或"新建文件夹"按钮开始使用', 'Use "Upload" or "New folder" above to get started')}</p>
</div>
) : (
<ScrollArea className="h-[calc(100vh-15rem)] min-h-[520px]">
<ScrollArea className="max-h-[calc(100vh-15rem)] min-h-[360px] lg:min-h-[520px]">
<div className="space-y-1 p-2">
{items.map((item) => (
<button
<div
key={item.path}
type="button"
className={`group flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors hover:bg-accent/30 ${
className={`group flex min-w-0 flex-col gap-2 rounded-lg border p-2 text-left transition-colors hover:bg-accent/30 sm:flex-row sm:items-center ${
selectedFile?.path === item.path ? 'border-primary bg-accent/40' : 'border-border bg-card'
}`}
onClick={() => {
if (item.type === 'directory') {
navigateTo(item.path);
} else {
void openFile(item);
}
}}
>
<div className="flex-shrink-0">
{item.type === 'directory' ? (
<Folder className="w-5 h-5 text-blue-500" />
) : (
<FileIcon name={item.name} contentType={item.content_type || undefined} />
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{item.name}</div>
<p className="text-xs text-muted-foreground">
{item.type === 'file' && formatSize(item.size)}
{item.modified && (
<>
{item.type === 'file' && ' · '}
{formatDate(item.modified)}
</>
<button
type="button"
className="flex min-h-[3.5rem] min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-2 text-left"
onClick={() => {
if (item.type === 'directory') {
navigateTo(item.path);
} else {
void openFile(item);
}
}}
>
<div className="flex-shrink-0">
{item.type === 'directory' ? (
<Folder className="w-5 h-5 text-blue-500" />
) : (
<FileIcon name={item.name} contentType={item.content_type || undefined} />
)}
</p>
</div>
</div>
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<div className="min-w-0 flex-1">
<div className={`text-sm font-medium ${containedLongTextClass}`}>{item.name}</div>
<p className={`text-xs text-muted-foreground ${containedLongTextClass}`}>
{item.type === 'file' && formatSize(item.size)}
{item.modified && (
<>
{item.type === 'file' && ' · '}
{formatDate(item.modified)}
</>
)}
</p>
</div>
</button>
<div className="flex shrink-0 items-center justify-end gap-1 opacity-100 md:opacity-0 md:transition-opacity md:group-hover:opacity-100">
{item.type === 'file' && (
<span
role="button"
tabIndex={0}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent"
<button
type="button"
className="inline-flex h-11 w-11 items-center justify-center rounded-md hover:bg-accent"
onClick={(event) => {
event.stopPropagation();
void handleDownload(item);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
void handleDownload(item);
}
}}
aria-label={`${pickAppText(locale, '下载', 'Download')} ${item.name}`}
title={pickAppText(locale, '下载', 'Download')}
>
<Download className="w-4 h-4" />
</span>
</button>
)}
<span
role="button"
tabIndex={0}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-destructive hover:bg-accent hover:text-destructive"
<button
type="button"
className="inline-flex h-11 w-11 items-center justify-center rounded-md text-destructive hover:bg-accent hover:text-destructive"
onClick={(event) => {
event.stopPropagation();
void handleDelete(item);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
void handleDelete(item);
}
}}
aria-label={`${pickAppText(locale, '删除', 'Delete')} ${item.name}`}
title={pickAppText(locale, '删除', 'Delete')}
>
<Trash2 className="w-4 h-4" />
</span>
</button>
</div>
</button>
</div>
))}
</div>
</ScrollArea>
@ -471,7 +477,7 @@ function FilePreviewPanel({
locale: AppLocale;
}) {
return (
<div className="min-h-[520px] rounded-lg border border-border bg-card p-4">
<div className="min-w-0 rounded-lg border border-border bg-card p-4 lg:min-h-[520px]">
{loading ? (
<div className="flex h-[420px] items-center justify-center text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
@ -485,16 +491,16 @@ function FilePreviewPanel({
</div>
) : (
<div className="space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
<div className="min-w-0">
<h2 className="break-all text-base font-semibold">{file.name}</h2>
<p className="mt-1 text-xs text-muted-foreground">
<h2 className={`text-base font-semibold ${containedLongTextClass}`}>{file.name}</h2>
<p className={`mt-1 text-xs text-muted-foreground ${containedLongTextClass}`}>
{formatSize(file.size)} · {formatDate(file.modified)} · {file.content_type}
{file.is_truncated ? ` · ${pickAppText(locale, '仅预览前 1MB', 'Showing first 1MB')}` : ''}
</p>
</div>
{downloadUrl && (
<Button variant="outline" size="sm" asChild>
<Button variant="outline" size="sm" className="h-11" asChild>
<a href={downloadUrl}>
<Download className="mr-2 h-4 w-4" />
{pickAppText(locale, '下载', 'Download')}
@ -514,11 +520,11 @@ function FilePreviewPanel({
<p className="text-sm font-medium">{pickAppText(locale, '该文件不能直接预览', 'This file cannot be previewed')}</p>
</div>
) : isMarkdown(file) ? (
<div className="prose prose-sm max-h-[620px] max-w-none overflow-auto text-black prose-a:text-black prose-code:text-black prose-headings:text-black prose-li:text-black prose-p:text-black prose-pre:bg-muted prose-pre:text-black prose-strong:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<div className={`prose prose-sm max-h-[620px] max-w-none overflow-auto text-black prose-a:text-black prose-code:text-black prose-headings:text-black prose-li:text-black prose-p:text-black prose-pre:bg-muted prose-pre:text-black prose-strong:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_*]:min-w-0 ${containedLongTextClass}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{file.content || ''}</ReactMarkdown>
</div>
) : (
<pre className="max-h-[620px] overflow-auto whitespace-pre-wrap rounded-md border border-border bg-background p-4 text-xs leading-5 text-black">
<pre className={`max-h-[620px] overflow-auto rounded-md border border-border bg-background p-4 text-xs leading-5 text-black ${containedPreservedLongTextClass}`}>
{file.content || ''}
</pre>
)}

View File

@ -168,10 +168,18 @@ export default function MarketplacePage() {
const totalPages = useMemo(() => Math.max(1, Math.ceil(total / 12)), [total]);
return (
<div className="mx-auto max-w-7xl p-6">
<div className="mx-auto mb-10 max-w-4xl">
<div className="mx-auto max-w-7xl space-y-6 p-4 sm:p-6">
<div className="mx-auto max-w-4xl space-y-5">
<div className="text-center">
<h1 className="text-2xl font-bold tracking-normal sm:text-3xl">
{t('市场', 'Marketplace')}
</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{t('搜索、查看并安装 SkillHub 技能。', 'Search, inspect, and install SkillHub skills.')}
</p>
</div>
<form
className="flex gap-3"
className="flex flex-col gap-3 sm:flex-row"
onSubmit={(event) => {
event.preventDefault();
setPage(0);
@ -187,7 +195,7 @@ export default function MarketplacePage() {
className="h-14 rounded-2xl pl-12 text-base"
/>
</div>
<Button type="submit" className="h-14 rounded-2xl px-10 text-base">
<Button type="submit" className="h-14 rounded-2xl px-10 text-base sm:w-auto">
{t('搜索', 'Search')}
</Button>
</form>
@ -195,9 +203,9 @@ export default function MarketplacePage() {
{error && (
<Card className="mb-6 border-destructive">
<CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{error}
<CardContent className="flex items-start gap-2 pt-6 text-sm text-destructive">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span className="min-w-0 break-words">{error}</span>
</CardContent>
</Card>
)}
@ -206,6 +214,7 @@ export default function MarketplacePage() {
<div className="space-y-5">
<Button
variant="ghost"
className="h-11"
onClick={() => {
setSelected(null);
setVersionDetail(null);
@ -239,7 +248,7 @@ export default function MarketplacePage() {
onOpenFile={(filePath) => void openFile(filePath)}
badges={
<>
<Badge variant="outline">@{selected.namespace}</Badge>
<Badge variant="outline" className="max-w-full break-all">@{selected.namespace}</Badge>
<Badge variant="outline">{t('下载', 'Downloads')}: {selected.downloadCount || 0}</Badge>
<Badge variant="outline">{t('收藏', 'Stars')}: {selected.starCount || 0}</Badge>
{selected.installed && (
@ -251,7 +260,7 @@ export default function MarketplacePage() {
</>
}
actions={
<Button onClick={installSelected} disabled={installing || detailLoading || versionLoading}>
<Button className="h-11" onClick={installSelected} disabled={installing || detailLoading || versionLoading}>
{installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
{selected.installed ? t('重新安装/更新', 'Reinstall/update') : t('安装', 'Install')}
</Button>
@ -283,7 +292,7 @@ export default function MarketplacePage() {
{label}
</Button>
))}
<span className="ml-4 text-sm font-medium text-muted-foreground">{t('筛选:', 'Filter:')}</span>
<span className="text-sm font-medium text-muted-foreground sm:ml-4">{t('筛选:', 'Filter:')}</span>
<Button size="sm" variant={starredOnly ? 'default' : 'outline'} onClick={() => setStarredOnly((value) => !value)}>
<Star className="mr-2 h-4 w-4" />
{t('只看已收藏', 'Starred only')}
@ -297,28 +306,34 @@ export default function MarketplacePage() {
) : (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => (
<Card key={`${item.namespace}/${item.slug}`} className="cursor-pointer transition hover:border-primary" onClick={() => void openDetail(item)}>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<CardTitle className="text-xl">{item.displayName || item.slug}</CardTitle>
<Badge variant="outline">@{item.namespace}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-5">
<p className="line-clamp-3 min-h-[4.5rem] text-sm leading-6 text-muted-foreground">{item.summary}</p>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<Badge variant="secondary">v{publishedVersion(item) || '-'}</Badge>
<span>{item.downloadCount || 0}</span>
<span>{item.starCount || 0}</span>
{item.installed && <Badge variant="outline">{t('已安装', 'Installed')}</Badge>}
</div>
</CardContent>
<Card key={`${item.namespace}/${item.slug}`} className="overflow-hidden transition hover:border-primary">
<button
type="button"
className="block h-full w-full text-left"
onClick={() => void openDetail(item)}
>
<CardHeader>
<div className="flex flex-col items-start gap-3 sm:flex-row sm:justify-between">
<CardTitle className="min-w-0 break-words text-xl">{item.displayName || item.slug}</CardTitle>
<Badge variant="outline" className="max-w-full break-all">@{item.namespace}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-5">
<p className="line-clamp-3 min-h-[4.5rem] break-words text-sm leading-6 text-muted-foreground">{item.summary}</p>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<Badge variant="secondary" className="max-w-full break-all">v{publishedVersion(item) || '-'}</Badge>
<span>{item.downloadCount || 0}</span>
<span>{item.starCount || 0}</span>
{item.installed && <Badge variant="outline">{t('已安装', 'Installed')}</Badge>}
</div>
</CardContent>
</button>
</Card>
))}
</div>
)}
<div className="flex items-center justify-center gap-3">
<div className="flex flex-wrap items-center justify-center gap-3">
<Button variant="outline" disabled={page <= 0} onClick={() => setPage((value) => Math.max(0, value - 1))}>
{t('上一页', 'Previous')}
</Button>

View File

@ -247,6 +247,9 @@ export default function MCPPage() {
};
const handleDelete = async (serverId: string) => {
if (!window.confirm(t('确定删除这个 MCP 服务吗?此操作不可撤销。', 'Delete this MCP server? This action cannot be undone.'))) {
return;
}
try {
await deleteMcpServer(serverId);
setSelectedServerId((current) => (current === serverId ? null : current));
@ -312,9 +315,9 @@ export default function MCPPage() {
}
return (
<div className="max-w-6xl mx-auto p-6 space-y-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<div className="mx-auto max-w-6xl space-y-6 p-4 sm:p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h1 className="text-2xl font-bold flex items-center gap-2">
<ServerCog className="w-6 h-6" />
{t('工具', 'Tools')}
@ -323,8 +326,8 @@ export default function MCPPage() {
{t('本地工具和在线工具都通过 MCP Server 暴露;本地工具按类别由真实 stdio MCP 子进程承载。', 'Local and online tools are both exposed through MCP servers. Local tool categories run as real stdio MCP subprocesses.')}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => void load(true)}>
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:justify-end">
<Button variant="outline" size="sm" className="h-11" onClick={() => void load(true)}>
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
{t('刷新', 'Refresh')}
</Button>
@ -333,12 +336,12 @@ export default function MCPPage() {
if (!open) resetForm();
}}>
<DialogTrigger asChild>
<Button size="sm">
<Button size="sm" className="h-11">
<Plus className="w-4 h-4 mr-2" />
{t('新增工具服务', 'Add tool server')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogContent className="max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{editingId ? t('编辑 MCP 服务', 'Edit MCP server') : t('新增 MCP 服务', 'Add MCP server')}</DialogTitle>
</DialogHeader>
@ -346,11 +349,11 @@ export default function MCPPage() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="id">ID</Label>
<Input id="id" value={form.id} onChange={(e) => setForm((s) => ({ ...s, id: e.target.value }))} required disabled={!!editingId} />
<Input id="id" className="h-11" value={form.id} onChange={(e) => setForm((s) => ({ ...s, id: e.target.value }))} required disabled={!!editingId} />
</div>
<div className="space-y-2">
<Label htmlFor="tool_timeout">{t('工具超时', 'Tool timeout')}</Label>
<Input id="tool_timeout" type="number" min="1" value={form.tool_timeout} onChange={(e) => setForm((s) => ({ ...s, tool_timeout: e.target.value }))} />
<Input id="tool_timeout" className="h-11" type="number" min="1" value={form.tool_timeout} onChange={(e) => setForm((s) => ({ ...s, tool_timeout: e.target.value }))} />
</div>
</div>
<Tabs
@ -390,6 +393,7 @@ export default function MCPPage() {
<Label htmlFor="url">{t('MCP Server 地址', 'MCP server URL')}</Label>
<Input
id="url"
className="h-11"
value={form.url}
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
placeholder="http://localhost:3001/mcp"
@ -403,7 +407,7 @@ export default function MCPPage() {
id="auth_mode"
value={form.auth_mode}
onChange={(e) => setForm((s) => ({ ...s, auth_mode: e.target.value }))}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
className="flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="none">none</option>
<option value="oauth_backend_token">oauth_backend_token</option>
@ -452,6 +456,7 @@ export default function MCPPage() {
<Label htmlFor="command">{t('命令', 'Command')}</Label>
<Input
id="command"
className="h-11"
value={form.command}
onChange={(e) => setForm((s) => ({ ...s, command: e.target.value }))}
placeholder="npx"
@ -462,6 +467,7 @@ export default function MCPPage() {
<Label htmlFor="args">{t('参数', 'Arguments')}</Label>
<Input
id="args"
className="h-11"
value={form.args}
onChange={(e) => setForm((s) => ({ ...s, args: e.target.value }))}
placeholder="-y @modelcontextprotocol/server-github"
@ -470,11 +476,11 @@ export default function MCPPage() {
</div>
</TabsContent>
</Tabs>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
<div className="sticky bottom-0 -mx-6 -mb-6 flex justify-end gap-2 border-t bg-background px-6 py-4">
<Button type="button" variant="outline" className="h-11" onClick={() => setDialogOpen(false)}>
{t('取消', 'Cancel')}
</Button>
<Button type="submit" disabled={submitting}>
<Button type="submit" className="h-11" disabled={submitting}>
{submitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
{t('保存', 'Save')}
</Button>
@ -488,7 +494,7 @@ export default function MCPPage() {
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-destructive text-sm">
<div className="flex items-center gap-2 break-words text-sm text-destructive">
<AlertCircle className="w-4 h-4" />
{error}
</div>
@ -500,9 +506,9 @@ export default function MCPPage() {
setToolTab(value as 'local' | 'online');
setSelectedServerId(null);
}} className="space-y-4">
<TabsList>
<TabsTrigger value="local">{t('本地工具', 'Local tools')}</TabsTrigger>
<TabsTrigger value="online">{t('在线工具', 'Online tools')}</TabsTrigger>
<TabsList className="h-auto min-h-11">
<TabsTrigger value="local" className="h-11 px-4">{t('本地工具', 'Local tools')}</TabsTrigger>
<TabsTrigger value="online" className="h-11 px-4">{t('在线工具', 'Online tools')}</TabsTrigger>
</TabsList>
</Tabs>
@ -511,78 +517,73 @@ export default function MCPPage() {
{visibleServers.map((server) => (
<Card
key={server.id}
role="button"
tabIndex={0}
onClick={() => setSelectedServerId(server.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setSelectedServerId(server.id);
}
}}
className={cn(
'cursor-pointer transition-colors',
'min-w-0 transition-colors',
selectedServerId === server.id && 'border-primary bg-primary/5 shadow-sm'
)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="text-base">{server.name}</CardTitle>
<p className="text-xs text-muted-foreground mt-1 font-mono">{server.id}</p>
<div
role="button"
tabIndex={0}
onClick={() => setSelectedServerId(server.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setSelectedServerId(server.id);
}
}}
className="min-h-11 cursor-pointer rounded-t-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<CardTitle className="break-words text-base">{server.name}</CardTitle>
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">{server.id}</p>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2 sm:justify-end">
<Badge variant="outline">{transportLabel(server.transport, locale)}</Badge>
<Badge variant="secondary">{server.category || (server.kind === 'local' ? 'local' : 'online')}</Badge>
{server.managed && <Badge variant="outline">{t('内置', 'Built-in')}</Badge>}
<Badge variant={server.status === 'connected' ? 'default' : server.status === 'error' ? 'destructive' : 'secondary'}>
{serverStatusLabel(server.status, locale)}
</Badge>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap justify-end">
<Badge variant="outline">{transportLabel(server.transport, locale)}</Badge>
<Badge variant="secondary">{server.category || (server.kind === 'local' ? 'local' : 'online')}</Badge>
{server.managed && <Badge variant="outline">{t('内置', 'Built-in')}</Badge>}
<Badge variant={server.status === 'connected' ? 'default' : server.status === 'error' ? 'destructive' : 'secondary'}>
{serverStatusLabel(server.status, locale)}
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-3 text-sm">
{server.url && <div><span className="font-medium">URL:</span> <span className="text-muted-foreground break-all">{server.url}</span></div>}
{server.command && <div><span className="font-medium">{t('命令:', 'Command:')}</span> <span className="text-muted-foreground">{server.command} {(server.args || []).join(' ')}</span></div>}
{server.auth_mode && server.auth_mode !== 'none' && <div><span className="font-medium">{t('鉴权:', 'Auth:')}</span> <span className="text-muted-foreground">{server.auth_mode}</span></div>}
{(server.auth_audience || server.auth_mode === 'oauth_backend_token') && (
<div><span className="font-medium">Audience</span> <span className="text-muted-foreground">{server.auth_audience || resolveAuthAudience(server.id)}</span></div>
)}
{(server.auth_scopes || []).length > 0 && <div><span className="font-medium">Scopes</span> <span className="text-muted-foreground break-all">{(server.auth_scopes || []).join(', ')}</span></div>}
{server.auth_mode === 'oauth_backend_token' && (!server.auth_scopes || server.auth_scopes.length === 0) && (
<div><span className="font-medium">Scopes</span> <span className="text-muted-foreground">{t('由 AuthZ 动态决定', 'Derived from AuthZ')}</span></div>
)}
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
<span>{t(`${discoveredToolCount(server.id, tools, server.tool_count)} 个工具`, `${discoveredToolCount(server.id, tools, server.tool_count)} tools`)}</span>
<span>{selectedServerId === server.id ? t('已选中', 'Selected') : t('点击查看工具', 'Click to view tools')}</span>
{server.last_error && <span className="text-rose-300">{server.last_error}</span>}
</div>
<div className="flex items-center gap-2 justify-end">
{!server.managed && (
<Button variant="outline" size="sm" onClick={(event) => {
event.stopPropagation();
openEdit(server);
}}>
{t('编辑', 'Edit')}
</Button>
</CardHeader>
<CardContent className="space-y-3 pt-0 text-sm">
{server.url && <div className="break-words"><span className="font-medium">URL:</span> <span className="break-all text-muted-foreground">{server.url}</span></div>}
{server.command && <div className="break-words"><span className="font-medium">{t('命令:', 'Command:')}</span> <span className="break-all text-muted-foreground">{server.command} {(server.args || []).join(' ')}</span></div>}
{server.auth_mode && server.auth_mode !== 'none' && <div className="break-words"><span className="font-medium">{t('鉴权:', 'Auth:')}</span> <span className="break-all text-muted-foreground">{server.auth_mode}</span></div>}
{(server.auth_audience || server.auth_mode === 'oauth_backend_token') && (
<div className="break-words"><span className="font-medium">Audience</span> <span className="break-all text-muted-foreground">{server.auth_audience || resolveAuthAudience(server.id)}</span></div>
)}
<Button variant="outline" size="sm" onClick={(event) => {
event.stopPropagation();
void handleTest(server.id);
}} disabled={testingId === server.id}>
{testingId === server.id ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <TestTube2 className="w-4 h-4 mr-2" />}
{t('测试', 'Test')}
{(server.auth_scopes || []).length > 0 && <div className="break-words"><span className="font-medium">Scopes</span> <span className="break-all text-muted-foreground">{(server.auth_scopes || []).join(', ')}</span></div>}
{server.auth_mode === 'oauth_backend_token' && (!server.auth_scopes || server.auth_scopes.length === 0) && (
<div className="break-words"><span className="font-medium">Scopes</span> <span className="text-muted-foreground">{t('由 AuthZ 动态决定', 'Derived from AuthZ')}</span></div>
)}
<div className="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{t(`${discoveredToolCount(server.id, tools, server.tool_count)} 个工具`, `${discoveredToolCount(server.id, tools, server.tool_count)} tools`)}</span>
<span>{selectedServerId === server.id ? t('已选中', 'Selected') : t('点击查看工具', 'Click to view tools')}</span>
{server.last_error && <span className="break-all text-destructive">{server.last_error}</span>}
</div>
</CardContent>
</div>
<CardContent className="flex flex-wrap items-center justify-end gap-2 pt-0">
{!server.managed && (
<Button variant="outline" size="sm" className="h-11" onClick={() => openEdit(server)}>
{t('编辑', 'Edit')}
</Button>
{!server.managed && (
<Button variant="outline" size="sm" onClick={(event) => {
event.stopPropagation();
void handleDelete(server.id);
}}>
<Trash2 className="w-4 h-4 mr-2" />
{t('删除', 'Delete')}
</Button>
)}
</div>
)}
<Button variant="outline" size="sm" className="h-11" onClick={() => void handleTest(server.id)} disabled={testingId === server.id}>
{testingId === server.id ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <TestTube2 className="w-4 h-4 mr-2" />}
{t('测试', 'Test')}
</Button>
{!server.managed && (
<Button variant="outline" size="sm" className="h-11" onClick={() => void handleDelete(server.id)}>
<Trash2 className="w-4 h-4 mr-2" />
{t('删除', 'Delete')}
</Button>
)}
</CardContent>
</Card>
))}
@ -595,9 +596,9 @@ export default function MCPPage() {
)}
</div>
<Card>
<Card className="min-w-0">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<CardTitle className="flex items-center gap-2 break-words text-base">
<Wrench className="w-4 h-4" />
{selectedServer ? t(`${selectedServer.name} 的工具`, `${selectedServer.name} tools`) : t('工具详情', 'Tool details')}
</CardTitle>
@ -613,12 +614,12 @@ export default function MCPPage() {
)}
{selectedToolGroup && (
<div className="space-y-2">
<div className="text-sm font-medium">{selectedToolGroup.server_id}</div>
<div className="break-all text-sm font-medium">{selectedToolGroup.server_id}</div>
<div className="space-y-2">
{selectedToolGroup.tools.map((tool) => (
<div key={String(tool.name)} className="rounded-md border border-border/70 px-3 py-2 bg-background/60">
<div className="text-sm font-medium">{String(tool.tool_name || tool.name)}</div>
<div className="text-xs text-muted-foreground mt-1 whitespace-pre-wrap break-words">
<div key={String(tool.name)} className="min-w-0 rounded-md border border-border/70 bg-background/60 px-3 py-2">
<div className="break-all text-sm font-medium">{String(tool.tool_name || tool.name)}</div>
<div className="mt-1 whitespace-pre-wrap break-words text-xs text-muted-foreground">
{String(tool.description || '—')}
</div>
</div>

View File

@ -9,6 +9,7 @@ import { getNotification, sendMessage } from '@/lib/api';
import type { ChatMessage, NotificationDetail } from '@/types';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { containedLongTextClass } from '@/lib/text-wrapping';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
@ -27,6 +28,7 @@ export default function NotificationDetailPage() {
const [submitting, setSubmitting] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const replyTextareaId = `notification-reply-${scheduledRunId}`;
const load = React.useCallback(async () => {
setLoading(true);
@ -94,8 +96,8 @@ export default function NotificationDetailPage() {
if (!detail) {
return (
<main className="mx-auto max-w-4xl px-6 py-8">
<Link href="/notifications" className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
<main className="mx-auto max-w-4xl px-4 py-6 sm:px-6 sm:py-8">
<Link href="/notifications" className="inline-flex h-11 items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-4 w-4" />
{pickAppText(locale, '返回通知', 'Back to notifications')}
</Link>
@ -106,29 +108,29 @@ export default function NotificationDetailPage() {
return (
<main className="flex h-[calc(100vh-4rem)] flex-col bg-background">
<div className="border-b border-[#E6E1DE] bg-[#F7F6F5] px-6 py-4">
<div className="border-b border-[#E6E1DE] bg-[#F7F6F5] px-4 py-4 sm:px-6">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<Link href="/notifications" className="mb-2 inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
<Link href="/notifications" className="mb-2 inline-flex h-11 items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-4 w-4" />
{pickAppText(locale, '通知列表', 'Notifications')}
</Link>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h1 className="truncate text-xl font-semibold">{detail.title || detail.job_name}</h1>
<Badge variant={detail.status === 'error' ? 'destructive' : 'secondary'}>{detail.status}</Badge>
{detail.engaged && <Badge>{pickAppText(locale, '已接入 Task', 'Task linked')}</Badge>}
<h1 className={`min-w-0 max-w-full text-lg font-semibold sm:text-xl ${containedLongTextClass}`}>{detail.title || detail.job_name}</h1>
<Badge variant={detail.status === 'error' ? 'destructive' : 'secondary'} className="shrink-0">{detail.status}</Badge>
{detail.engaged && <Badge className="shrink-0">{pickAppText(locale, '已接入 Task', 'Task linked')}</Badge>}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{pickAppText(locale, '生成时间', 'Generated')}: {formatTime(detail.started_at)}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => void load()}>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" className="h-11" onClick={() => void load()}>
<RefreshCw className="mr-2 h-4 w-4" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button>
{detail.task_id && (
<Button asChild size="sm">
<Button asChild size="sm" className="h-11">
<Link href={`/tasks/${encodeURIComponent(detail.task_id)}`}>{pickAppText(locale, '查看任务', 'Open task')}</Link>
</Button>
)}
@ -136,7 +138,7 @@ export default function NotificationDetailPage() {
</div>
</div>
{error && <div className="mx-auto w-full max-w-6xl px-6 pt-3 text-sm text-destructive">{error}</div>}
{error && <div className={`mx-auto w-full max-w-6xl px-4 pt-3 text-sm text-destructive sm:px-6 ${containedLongTextClass}`}>{error}</div>}
<div className="min-h-0 flex-1">
<ChatWorkbench
@ -154,13 +156,15 @@ export default function NotificationDetailPage() {
/>
</div>
<div className="border-t border-[#E6E1DE] bg-background px-6 py-4">
<div className="border-t border-[#E6E1DE] bg-background px-4 py-4 sm:px-6">
<div className="mx-auto max-w-5xl">
<div className="mb-2 flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant={intent === 'revise_once' ? 'default' : 'outline'}
className="h-11"
aria-pressed={intent === 'revise_once'}
onClick={() => setIntent('revise_once')}
>
<RefreshCw className="mr-2 h-4 w-4" />
@ -170,13 +174,15 @@ export default function NotificationDetailPage() {
type="button"
size="sm"
variant={intent === 'update_future' ? 'default' : 'outline'}
className="h-11"
aria-pressed={intent === 'update_future'}
onClick={() => setIntent('update_future')}
>
<Settings2 className="mr-2 h-4 w-4" />
{pickAppText(locale, '以后按这样', 'Apply going forward')}
</Button>
{detail.engaged && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<span className="inline-flex min-h-11 items-center gap-1 text-xs text-muted-foreground">
<Check className="h-3.5 w-3.5" />
{pickAppText(locale, '这条通知已经接入 Task', 'This notification is linked to a Task')}
</span>
@ -184,7 +190,13 @@ export default function NotificationDetailPage() {
</div>
{intent && (
<div className="rounded-[20px] border border-[#E6E1DE] bg-white p-3 shadow-[0_6px_18px_rgba(0,0,0,0.06)]">
<label htmlFor={replyTextareaId} className="mb-2 block px-2 text-xs font-medium text-muted-foreground">
{intent === 'update_future'
? pickAppText(locale, '以后这类通知的调整说明', 'Future notification adjustment')
: pickAppText(locale, '本次通知的修改说明', 'Revision note for this notification')}
</label>
<textarea
id={replyTextareaId}
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder={
@ -192,10 +204,10 @@ export default function NotificationDetailPage() {
? pickAppText(locale, '告诉我以后这类通知要怎么调整...', 'Describe how future notifications should change...')
: pickAppText(locale, '告诉我这次内容要怎么改...', 'Describe how this result should change...')
}
className="block min-h-20 w-full resize-none border-0 bg-transparent px-2 py-1 text-sm leading-6 outline-none"
className={`block min-h-20 w-full resize-none border-0 bg-transparent px-2 py-1 text-sm leading-6 outline-none ${containedLongTextClass}`}
/>
<div className="flex justify-end">
<Button size="sm" onClick={() => void submitReply()} disabled={!input.trim() || submitting}>
<Button size="sm" className="h-11" onClick={() => void submitReply()} disabled={!input.trim() || submitting}>
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
{pickAppText(locale, '发送', 'Send')}
</Button>

View File

@ -8,6 +8,7 @@ import { listNotifications } from '@/lib/api';
import type { NotificationRun } from '@/types';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { containedLongTextClass } from '@/lib/text-wrapping';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@ -40,18 +41,18 @@ export default function NotificationsPage() {
};
return (
<main className="mx-auto flex h-[calc(100vh-4rem)] max-w-6xl flex-col px-6 py-8">
<main className="mx-auto flex h-[calc(100vh-4rem)] max-w-6xl flex-col px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<div className="min-w-0">
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Bell className="h-5 w-5" />
{pickAppText(locale, '通知', 'Notifications')}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
{pickAppText(locale, '定时任务生成的日报、提醒和总结会固定出现在这里。', 'Scheduled reports, reminders, and summaries appear here.')}
</p>
</div>
<Button onClick={() => void load()} variant="outline" size="sm">
<Button onClick={() => void load()} variant="outline" size="sm" className="h-11">
<RefreshCw className="mr-2 h-4 w-4" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button>
@ -83,21 +84,21 @@ export default function NotificationsPage() {
<Link
key={item.scheduled_run_id}
href={`/notifications/${encodeURIComponent(item.scheduled_run_id)}`}
className="grid gap-3 px-5 py-4 transition-colors hover:bg-[#F7F6F5] md:grid-cols-[minmax(0,1fr)_180px_110px_24px]"
className="grid min-w-0 gap-3 px-4 py-4 transition-colors hover:bg-[#F7F6F5] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset sm:px-5 md:grid-cols-[minmax(0,1fr)_180px_110px_24px]"
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">{item.title || item.job_name}</span>
{item.engaged && <Badge variant="secondary">{pickAppText(locale, '已接入 Task', 'Task linked')}</Badge>}
{item.status === 'error' && <Badge variant="destructive">{pickAppText(locale, '错误', 'Error')}</Badge>}
<span className={`min-w-0 flex-1 font-medium ${containedLongTextClass}`}>{item.title || item.job_name}</span>
{item.engaged && <Badge variant="secondary" className="shrink-0">{pickAppText(locale, '已接入 Task', 'Task linked')}</Badge>}
{item.status === 'error' && <Badge variant="destructive" className="shrink-0">{pickAppText(locale, '错误', 'Error')}</Badge>}
</div>
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{item.output || item.message}</p>
<p className={`mt-1 line-clamp-2 text-sm text-muted-foreground ${containedLongTextClass}`}>{item.output || item.message}</p>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
<Clock3 className="h-4 w-4" />
{formatTime(item.started_at)}
</div>
<div className="text-sm text-muted-foreground">{item.job_name}</div>
<div className={`text-sm text-muted-foreground ${containedLongTextClass}`}>{item.job_name}</div>
<ArrowRight className="hidden h-4 w-4 self-center text-muted-foreground md:block" />
</Link>
))}

View File

@ -318,7 +318,7 @@ function renderPlainText(content: string): React.ReactNode[] {
href={part}
target="_blank"
rel="noreferrer noopener"
className="text-primary underline underline-offset-2 break-all"
className="inline-block min-h-11 max-w-full break-all py-2 text-primary underline underline-offset-2"
>
{part}
</a>
@ -461,7 +461,9 @@ export default function OutlookPage() {
if (!background) {
setStatusLoading(false);
}
if (!nextStatus.configured) {
if (nextStatus.configured) {
await loadOverview(options?.preserveOverview ?? background);
} else {
setOverview(null);
setOverviewLoading(false);
}
@ -647,6 +649,9 @@ export default function OutlookPage() {
};
const handleDisconnect = async () => {
if (!window.confirm(t('确定断开 Outlook 连接吗?已保存的连接凭据会被移除。', 'Disconnect Outlook? Saved connection credentials will be removed.'))) {
return;
}
setDisconnecting(true);
setError(null);
try {
@ -671,9 +676,7 @@ export default function OutlookPage() {
const refreshOverview = async () => {
await loadStatus(true, { preserveOverview: true });
if (activeView === 'settings' && isConfigured) {
await loadOverview(true);
} else if (activeView === 'inbox') {
if (activeView === 'inbox') {
await loadMailboxPage('inbox', inboxPage?.page.skip ?? 0);
} else if (activeView === 'sent') {
await loadMailboxPage('sent', sentPage?.page.skip ?? 0);
@ -684,14 +687,14 @@ export default function OutlookPage() {
return (
<div className="min-h-full">
<div className="mx-auto max-w-7xl space-y-6 p-6">
<div className="mx-auto max-w-7xl space-y-6 p-4 sm:p-6">
<section className="rounded-2xl border bg-card px-4 py-4 shadow-sm">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="flex flex-wrap items-center gap-2 text-sm">
<div className="mr-2 flex items-center gap-2 text-lg font-semibold text-foreground">
<div className="flex min-w-0 flex-wrap items-center gap-2 text-sm">
<h1 className="mr-2 flex min-w-0 items-center gap-2 text-lg font-semibold text-foreground">
<Mail className="h-5 w-5" />
Outlook
</div>
</h1>
{statusPending ? (
<>
<Skeleton className="h-6 w-20 rounded-full" />
@ -710,9 +713,9 @@ export default function OutlookPage() {
{status?.mcp_registered ? t('MCP 已注册', 'MCP registered') : t('MCP 未注册', 'MCP not registered')}
</Badge>
<Badge variant="secondary">{status?.provider || 'ews'}</Badge>
<span className="text-muted-foreground">{t('邮箱', 'Mailbox')} {overview?.mailbox || status?.saved?.email || '-'}</span>
<span className="text-muted-foreground">{t('时区', 'Timezone')} {status?.saved?.default_timezone || overview?.timezone || form.default_timezone}</span>
<span className="text-muted-foreground">
<span className="min-w-0 break-all text-muted-foreground">{t('邮箱', 'Mailbox')} {overview?.mailbox || status?.saved?.email || '-'}</span>
<span className="min-w-0 break-all text-muted-foreground">{t('时区', 'Timezone')} {status?.saved?.default_timezone || overview?.timezone || form.default_timezone}</span>
<span className="min-w-0 break-words text-muted-foreground">
{t('最近刷新', 'Last refreshed')} {formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined, locale)}
</span>
</>
@ -727,7 +730,7 @@ export default function OutlookPage() {
<TopStat label={t('日程', 'Calendar')} value={String(eventCount)} loading={overviewPending} />
</>
) : null}
<Button variant="outline" size="sm" onClick={() => void refreshOverview()}>
<Button variant="outline" size="sm" className="h-11" onClick={() => void refreshOverview()}>
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
{t('刷新', 'Refresh')}
</Button>
@ -740,7 +743,7 @@ export default function OutlookPage() {
<CardContent className="pt-6">
<div className="flex items-start gap-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
<span className="min-w-0 flex-1 break-all">{error}</span>
</div>
</CardContent>
</Card>
@ -752,7 +755,7 @@ export default function OutlookPage() {
{overviewWarnings.map((warning, index) => (
<div key={`${warning}-${index}`} className="flex items-start gap-3">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{warning}</span>
<span className="min-w-0 flex-1 break-all">{warning}</span>
</div>
))}
</CardContent>
@ -771,7 +774,7 @@ export default function OutlookPage() {
<TabsTrigger
key={view.id}
value={view.id}
className="h-auto rounded-xl border border-transparent px-4 py-3 data-[state=active]:border-border data-[state=active]:shadow-sm"
className="min-h-11 rounded-xl border border-transparent px-4 py-3 data-[state=active]:border-border data-[state=active]:shadow-sm"
>
<div className="flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-3">
@ -872,53 +875,67 @@ export default function OutlookPage() {
</CardHeader>
<CardContent className="space-y-5 pt-6">
<div className="grid gap-4 md:grid-cols-2">
<Field label={t('邮箱地址', 'Email address')} required>
<Field id="outlook-email" label={t('邮箱地址', 'Email address')} required>
<Input
id="outlook-email"
className="h-11"
value={form.email}
onChange={(event) => updateField('email', event.target.value)}
placeholder="you@boardware.com"
/>
</Field>
<Field label={t('用户名', 'Username')}>
<Field id="outlook-username" label={t('用户名', 'Username')}>
<Input
id="outlook-username"
className="h-11"
value={form.username}
onChange={(event) => updateField('username', event.target.value)}
placeholder={t('留空时默认取邮箱前缀', 'Leave blank to default to the email prefix')}
/>
</Field>
<Field label={t('密码', 'Password')} required>
<Field id="outlook-password" label={t('密码', 'Password')} required>
<Input
id="outlook-password"
className="h-11"
type="password"
value={form.password}
onChange={(event) => updateField('password', event.target.value)}
placeholder={t('请输入邮箱密码', 'Enter the mailbox password')}
/>
</Field>
<Field label={t('域', 'Domain')}>
<Field id="outlook-domain" label={t('域', 'Domain')}>
<Input
id="outlook-domain"
className="h-11"
value={form.domain}
onChange={(event) => updateField('domain', event.target.value)}
placeholder="boardware.com.mo"
/>
</Field>
<Field label="EWS URL">
<Field id="outlook-service-endpoint" label="EWS URL">
<Input
id="outlook-service-endpoint"
className="h-11"
value={form.service_endpoint}
onChange={(event) => updateField('service_endpoint', event.target.value)}
placeholder="https://mail.boardware.com.mo/EWS/Exchange.asmx"
disabled={form.autodiscover}
/>
</Field>
<Field label="Server Host">
<Field id="outlook-server" label="Server Host">
<Input
id="outlook-server"
className="h-11"
value={form.server}
onChange={(event) => updateField('server', event.target.value)}
placeholder="mail.boardware.com.mo"
disabled={form.autodiscover}
/>
</Field>
<Field label={t('时区', 'Timezone')}>
<Field id="outlook-timezone" label={t('时区', 'Timezone')}>
<Input
id="outlook-timezone"
className="h-11"
value={form.default_timezone}
onChange={(event) => updateField('default_timezone', event.target.value)}
placeholder="Asia/Shanghai"
@ -944,16 +961,17 @@ export default function OutlookPage() {
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" onClick={handleTest} disabled={!canTest || testing}>
<Button variant="outline" className="h-11" onClick={handleTest} disabled={!canTest || testing}>
{testing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
{t('测试连接', 'Test connection')}
</Button>
<Button onClick={handleConnect} disabled={!canTest || saving}>
<Button className="h-11" onClick={handleConnect} disabled={!canTest || saving}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
{t('保存并启用', 'Save and enable')}
</Button>
<Button
variant="outline"
className="h-11"
onClick={handleDisconnect}
disabled={!status?.configured || disconnecting}
>
@ -966,8 +984,8 @@ export default function OutlookPage() {
<div className="rounded-3xl border bg-muted/30 p-4 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="default">{t('测试成功', 'Test succeeded')}</Badge>
<span className="text-muted-foreground">{testResult.mailbox}</span>
<span className="text-muted-foreground">{t('用户名', 'Username')}: {testResult.resolved_username}</span>
<span className="break-all text-muted-foreground">{testResult.mailbox}</span>
<span className="break-all text-muted-foreground">{t('用户名', 'Username')}: {testResult.resolved_username}</span>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-3">
<MiniStat label={t('检测到文件夹', 'Detected folders')} value={String(testResult.sample.folders.length)} />
@ -979,7 +997,7 @@ export default function OutlookPage() {
{testWarnings.map((warning, index) => (
<div key={`${warning}-${index}`} className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{warning}</span>
<span className="min-w-0 flex-1 break-all">{warning}</span>
</div>
))}
</div>
@ -1054,10 +1072,10 @@ export default function OutlookPage() {
</Tabs>
<Dialog open={Boolean(selectedMessageRef)} onOpenChange={(open) => !open && setSelectedMessageRef(null)}>
<DialogContent className="sm:max-w-5xl">
<DialogContent className="bottom-4 left-4 right-4 top-4 max-h-none w-auto max-w-none translate-x-0 translate-y-0 overflow-y-auto data-[state=open]:slide-in-from-left-0 data-[state=open]:slide-in-from-top-0 data-[state=closed]:slide-out-to-left-0 data-[state=closed]:slide-out-to-top-0 sm:bottom-auto sm:left-[50%] sm:right-auto sm:top-[50%] sm:max-h-[calc(100dvh-2rem)] sm:w-[calc(100vw-2rem)] sm:max-w-5xl sm:translate-x-[-50%] sm:translate-y-[-50%] sm:data-[state=open]:slide-in-from-left-1/2 sm:data-[state=open]:slide-in-from-top-[48%] sm:data-[state=closed]:slide-out-to-left-1/2 sm:data-[state=closed]:slide-out-to-top-[48%]">
<DialogHeader>
<DialogTitle>{selectedMessage?.subject || t('邮件详情', 'Message details')}</DialogTitle>
<DialogDescription>
<DialogTitle className="break-words pr-8 leading-6">{selectedMessage?.subject || t('邮件详情', 'Message details')}</DialogTitle>
<DialogDescription className="break-words">
{selectedMessage?.receivedDateTime ? formatDateTime(selectedMessage.receivedDateTime, locale) : t('正在加载', 'Loading')}
</DialogDescription>
</DialogHeader>
@ -1066,7 +1084,7 @@ export default function OutlookPage() {
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : selectedMessage ? (
<div className="grid gap-4 lg:grid-cols-[280px,1fr]">
<div className="grid min-w-0 gap-4 lg:grid-cols-[280px,1fr]">
<div className="space-y-4 rounded-2xl border bg-muted/20 p-4 text-sm">
<InfoRow label={t('发件人', 'From')} value={mailboxLabel(selectedMessage.from)} />
<InfoRow
@ -1085,7 +1103,7 @@ export default function OutlookPage() {
</div>
</div>
<div className="overflow-hidden rounded-2xl border bg-background">
<div className="min-w-0 overflow-hidden rounded-2xl border bg-background">
<div className="border-b px-4 py-3 text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
{t('正文', 'Body')}
</div>
@ -1115,10 +1133,10 @@ export default function OutlookPage() {
</Dialog>
<Dialog open={Boolean(selectedEvent)} onOpenChange={(open) => !open && setSelectedEvent(null)}>
<DialogContent className="sm:max-w-2xl">
<DialogContent className="bottom-4 left-4 right-4 top-4 max-h-none w-auto max-w-none translate-x-0 translate-y-0 overflow-y-auto data-[state=open]:slide-in-from-left-0 data-[state=open]:slide-in-from-top-0 data-[state=closed]:slide-out-to-left-0 data-[state=closed]:slide-out-to-top-0 sm:bottom-auto sm:left-[50%] sm:right-auto sm:top-[50%] sm:max-h-[calc(100dvh-2rem)] sm:w-[calc(100vw-2rem)] sm:max-w-2xl sm:translate-x-[-50%] sm:translate-y-[-50%] sm:data-[state=open]:slide-in-from-left-1/2 sm:data-[state=open]:slide-in-from-top-[48%] sm:data-[state=closed]:slide-out-to-left-1/2 sm:data-[state=closed]:slide-out-to-top-[48%]">
<DialogHeader>
<DialogTitle>{selectedEvent?.subject || t('日程详情', 'Event details')}</DialogTitle>
<DialogDescription>
<DialogTitle className="break-words pr-8 leading-6">{selectedEvent?.subject || t('日程详情', 'Event details')}</DialogTitle>
<DialogDescription className="break-words">
{selectedEvent
? `${formatDateTime(selectedEvent.start?.dateTime, locale)} - ${formatDateTime(selectedEvent.end?.dateTime, locale)}`
: t('日程详情', 'Event details')}
@ -1135,7 +1153,7 @@ export default function OutlookPage() {
<Separator />
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{t('说明', 'Notes')}</p>
<div className="rounded-lg border bg-muted/40 p-3 whitespace-pre-wrap">
<div className="rounded-lg border bg-muted/40 p-3 whitespace-pre-wrap break-words">
{selectedEvent.bodyPreview || t('没有更多说明。', 'No additional notes.')}
</div>
</div>
@ -1149,17 +1167,19 @@ export default function OutlookPage() {
}
function Field({
id,
label,
required = false,
children,
}: {
id: string;
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<div className="space-y-2">
<Label className="text-sm font-medium">
<Label htmlFor={id} className="text-sm font-medium">
{label}
{required ? <span className="ml-1 text-destructive">*</span> : null}
</Label>
@ -1235,25 +1255,26 @@ function MessageCard({
const pageLabel = page ? t(`${currentPage} 页 · 本页 ${page.returned}`, `Page ${currentPage} · ${page.returned} messages`) : t('正在读取邮件…', 'Loading messages...');
return (
<Card className="rounded-[28px] shadow-sm">
<CardHeader className="flex flex-row items-center justify-between gap-4 border-b pb-5">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2 text-base">
<Card className="min-w-0 rounded-[28px] shadow-sm">
<CardHeader className="flex flex-col gap-4 border-b pb-5 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<CardTitle className="flex items-center gap-2 break-words text-base">
{icon}
{title}
</CardTitle>
<p className="text-sm text-muted-foreground">{loading ? t('正在读取邮件…', 'Loading messages...') : pageLabel}</p>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
<div className="flex flex-wrap items-center gap-2">
<Button variant="ghost" size="sm" className="h-11 w-11 p-0" aria-label={t('刷新邮件', 'Refresh mail')} title={t('刷新邮件', 'Refresh mail')} onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
<Button variant="outline" size="sm" onClick={onPreviousPage} disabled={!page || page.skip === 0 || refreshing}>
<Button variant="outline" size="sm" className="h-11" onClick={onPreviousPage} disabled={!page || page.skip === 0 || refreshing}>
{t('上一页', 'Previous')}
</Button>
<Button
variant="outline"
size="sm"
className="h-11"
onClick={onNextPage}
disabled={!page || !page.has_more || refreshing}
>
@ -1284,17 +1305,17 @@ function MessageCard({
key={item.id || `${item.subject}-${item.receivedDateTime}`}
type="button"
onClick={() => item.id && onOpen(item)}
className="w-full rounded-2xl border bg-card p-4 text-left transition-colors hover:bg-muted/40"
className="min-h-11 w-full rounded-2xl border bg-card p-4 text-left transition-colors hover:bg-muted/40"
>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">{item.subject || t('(无主题)', '(No subject)')}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{mailboxLabel(item.from)}</p>
<p className="mt-3 line-clamp-2 text-sm leading-6 text-muted-foreground">
<p className="break-words font-medium text-foreground">{item.subject || t('(无主题)', '(No subject)')}</p>
<p className="mt-1 break-all text-xs text-muted-foreground">{mailboxLabel(item.from)}</p>
<p className="mt-3 line-clamp-2 break-words text-sm leading-6 text-muted-foreground">
{item.bodyPreview || t('没有预览内容。', 'No preview available.')}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 lg:flex-col lg:items-end">
<div className="flex shrink-0 flex-wrap items-center gap-2 lg:flex-col lg:items-end">
<Badge variant={item.isRead ? 'secondary' : 'default'}>
{item.isRead ? t('已读', 'Read') : t('未读', 'Unread')}
</Badge>
@ -1357,10 +1378,10 @@ function EventCard({
});
return (
<Card className="rounded-[28px] shadow-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 border-b pb-5">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2 text-base">
<Card className="min-w-0 rounded-[28px] shadow-sm">
<CardHeader className="flex flex-col gap-4 space-y-0 border-b pb-5 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<CardTitle className="flex items-center gap-2 break-words text-base">
<CalendarDays className="h-4 w-4" />
{t('日程安排', 'Schedule')}
</CardTitle>
@ -1368,17 +1389,17 @@ function EventCard({
{formatDayLabel(weekDays[0], locale)} - {formatDayLabel(weekDays[weekDays.length - 1], locale)}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onPreviousWeek} disabled={refreshing}>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" className="h-11" onClick={onPreviousWeek} disabled={refreshing}>
{t('上一周', 'Previous week')}
</Button>
<Button variant="outline" size="sm" onClick={onCurrentWeek} disabled={refreshing}>
<Button variant="outline" size="sm" className="h-11" onClick={onCurrentWeek} disabled={refreshing}>
{t('本周', 'This week')}
</Button>
<Button variant="outline" size="sm" onClick={onNextWeek} disabled={refreshing}>
<Button variant="outline" size="sm" className="h-11" onClick={onNextWeek} disabled={refreshing}>
{t('下一周', 'Next week')}
</Button>
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
<Button variant="ghost" size="sm" className="h-11 w-11 p-0" aria-label={t('刷新日程', 'Refresh calendar')} title={t('刷新日程', 'Refresh calendar')} onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
</div>
@ -1397,7 +1418,7 @@ function EventCard({
) : (
<div className="grid gap-3 lg:grid-cols-2 2xl:grid-cols-3">
{eventsByDay.map((day) => (
<div key={day.key} className="rounded-2xl border bg-card p-4">
<div key={day.key} className="min-w-0 rounded-2xl border bg-card p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-medium text-foreground">{day.label}</p>
@ -1413,13 +1434,13 @@ function EventCard({
key={item.id || `${item.subject}-${item.start?.dateTime}`}
type="button"
onClick={() => onOpen(item)}
className="w-full rounded-xl border bg-background p-3 text-left transition-colors hover:bg-muted/40"
className="min-h-11 w-full rounded-xl border bg-background p-3 text-left transition-colors hover:bg-muted/40"
>
<p className="font-medium text-foreground">{item.subject || t('(无主题)', '(No subject)')}</p>
<p className="break-words font-medium text-foreground">{item.subject || t('(无主题)', '(No subject)')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{formatTime(item.start?.dateTime, locale)} - {formatTime(item.end?.dateTime, locale)}
</p>
<p className="mt-2 text-sm text-muted-foreground">
<p className="mt-2 break-words text-sm text-muted-foreground">
{item.location?.displayName || t('未设置地点', 'No location set')}
</p>
</button>

View File

@ -2,15 +2,24 @@
import Link from 'next/link';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Brain, Plus, Send, Trash2, X } from 'lucide-react';
import { Brain, Menu, Plus, Send, Trash2, X } from 'lucide-react';
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
import { CurrentSessionProgressSidebar } from '@/components/chat-workbench/CurrentSessionProgressSidebar';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
archiveSession,
createSession,
getActiveTask,
getBackendTask,
getSession,
getSessionProcess,
listSessions,
@ -27,9 +36,9 @@ import {
} from '@/lib/chat-messages';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { buildSessionProgressView } from '@/lib/session-progress';
import { useChatStore } from '@/lib/store';
import type { ActiveTask, ChatMessage, FileAttachment, SessionUpdatedEvent, WsEvent } from '@/types';
import { buildTaskTimelineView } from '@/lib/task-timeline-view';
import type { ActiveTask, BackendTask, ChatMessage, FileAttachment, SessionUpdatedEvent, WsEvent } from '@/types';
function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is SessionUpdatedEvent {
return data.type === 'session_updated' && typeof data.session_id === 'string';
@ -86,13 +95,17 @@ export default function ChatPage() {
const [thinkingModeEnabled, setThinkingModeEnabled] = useState(loadThinkingModePreference);
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
const [activeTaskDetail, setActiveTaskDetail] = useState<BackendTask | null>(null);
const [revisionTargetRunId, setRevisionTargetRunId] = useState<string | null>(null);
const [documentHidden, setDocumentHidden] = useState(isDocumentHidden);
const [sessionDrawerOpen, setSessionDrawerOpen] = useState(false);
const [archiveTargetSessionId, setArchiveTargetSessionId] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messageViewportRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadSessionReqSeq = useRef(0);
const loadActiveTaskReqSeq = useRef(0);
const loadedSessionIdRef = useRef<string | null>(null);
const refreshSessionOnReconnectRef = useRef(false);
const hasConnectedRef = useRef(false);
@ -120,16 +133,15 @@ export default function ChatPage() {
);
const selectedSessionRunId = selectedRunId && sessionRunIds.has(selectedRunId) ? selectedRunId : null;
const sessionProgressView = useMemo(
const activeTaskTimelineView = useMemo(
() =>
buildSessionProgressView({
sessionId,
processRuns,
processEvents,
processArtifacts,
locale,
buildTaskTimelineView({
task: activeTaskDetail,
liveRuns: processRuns,
liveEvents: processEvents,
liveArtifacts: processArtifacts,
}),
[locale, processArtifacts, processEvents, processRuns, sessionId]
[activeTaskDetail, processArtifacts, processEvents, processRuns]
);
const loadSessions = useCallback(async () => {
@ -142,12 +154,34 @@ export default function ChatPage() {
}, []);
const loadActiveTask = useCallback(async (key: string) => {
const reqSeq = ++loadActiveTaskReqSeq.current;
try {
if (useChatStore.getState().sessionId !== key) return;
setActiveTask(await getActiveTask(key));
const nextActiveTask = await getActiveTask(key);
if (reqSeq !== loadActiveTaskReqSeq.current || useChatStore.getState().sessionId !== key) return;
setActiveTask(nextActiveTask);
if (!nextActiveTask) {
setActiveTaskDetail(null);
return;
}
setActiveTaskDetail((current) => (current?.task_id === nextActiveTask.task_id ? current : null));
try {
const detail = await getBackendTask(nextActiveTask.task_id);
if (reqSeq !== loadActiveTaskReqSeq.current || useChatStore.getState().sessionId !== key) return;
if (detail.is_open === false) {
setActiveTask(null);
setActiveTaskDetail(null);
return;
}
setActiveTaskDetail(detail);
} catch {
if (reqSeq === loadActiveTaskReqSeq.current && useChatStore.getState().sessionId === key) {
setActiveTaskDetail(null);
}
}
} catch {
if (useChatStore.getState().sessionId === key) {
if (reqSeq === loadActiveTaskReqSeq.current && useChatStore.getState().sessionId === key) {
setActiveTask(null);
setActiveTaskDetail(null);
}
}
}, []);
@ -194,6 +228,7 @@ export default function ChatPage() {
setIsThinking(false);
}
setActiveTask(null);
setActiveTaskDetail(null);
setRevisionTargetRunId(null);
setInput(useChatStore.getState().getInputDraft(sessionId));
void loadSessionMessages(sessionId);
@ -299,6 +334,7 @@ export default function ChatPage() {
useEffect(() => {
shouldSnapToLatestRef.current = true;
setSessionDrawerOpen(false);
}, [sessionId]);
useLayoutEffect(() => {
@ -474,6 +510,7 @@ export default function ChatPage() {
setSessionId(id);
setSelectedRunId(null);
setActiveTask(null);
setActiveTaskDetail(null);
setRevisionTargetRunId(null);
clearInputDraft(id);
setInput('');
@ -487,14 +524,15 @@ export default function ChatPage() {
void loadSessions();
};
const handleArchiveSession = async (key: string, e: React.MouseEvent) => {
e.stopPropagation();
const handleArchiveSession = async (key: string) => {
try {
await archiveSession(key);
setArchiveTargetSessionId(null);
useChatStore.getState().setSessions(useChatStore.getState().sessions.filter((session) => session.key !== key));
if (key === sessionId) {
setSessionId('web:default');
setActiveTask(null);
setActiveTaskDetail(null);
setRevisionTargetRunId(null);
clearInputDraft(key);
setInput(useChatStore.getState().getInputDraft('web:default'));
@ -514,9 +552,11 @@ export default function ChatPage() {
const handleSelectSession = (key: string) => {
setSelectedRunId(null);
setActiveTask(null);
setActiveTaskDetail(null);
setRevisionTargetRunId(null);
setInput(useChatStore.getState().getInputDraft(key));
setSessionId(key);
setSessionDrawerOpen(false);
};
const removePendingFile = useCallback((file: File) => {
@ -551,53 +591,101 @@ export default function ChatPage() {
return key;
};
return (
<div className="flex h-[calc(100vh-4rem)] bg-background">
<aside className="flex w-[280px] shrink-0 flex-col border-r border-[#E6E1DE] bg-[#F7F6F5]">
<div className="px-5 pb-5 pt-6">
<button
type="button"
onClick={handleNewSession}
className="flex h-11 w-full items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-[#342E2B]"
>
<Plus className="h-4 w-4" />
{pickAppText(locale, '新对话', 'New chat')}
</button>
</div>
<ScrollArea className="flex-1">
<div className="space-y-3 px-3 pb-6">
<div className="px-3 pb-2 text-[14px] text-muted-foreground">{pickAppText(locale, '最近对话', 'Recent chats')}</div>
{sessions.length === 0 && (
<p className="px-3 py-4 text-sm text-muted-foreground">{pickAppText(locale, '暂无对话记录', 'No chat history yet')}</p>
)}
{sessions.map((session) => (
const archiveTargetSessionName = archiveTargetSessionId ? formatSessionName(archiveTargetSessionId) : '';
const renderSessionSidebar = (variant: 'desktop' | 'drawer') => (
<>
<div className="px-5 pb-5 pt-6">
<button
type="button"
onClick={() => {
setSessionDrawerOpen(false);
void handleNewSession();
}}
className="flex h-11 w-full items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-[#342E2B]"
>
<Plus className="h-4 w-4" />
{pickAppText(locale, '新对话', 'New chat')}
</button>
</div>
<ScrollArea className="flex-1">
<div className="space-y-3 px-3 pb-6">
<div className="px-3 pb-2 text-[14px] text-muted-foreground">{pickAppText(locale, '最近对话', 'Recent chats')}</div>
{sessions.length === 0 && (
<p className="px-3 py-4 text-sm text-muted-foreground">{pickAppText(locale, '暂无对话记录', 'No chat history yet')}</p>
)}
{sessions.map((session) => {
const sessionName = formatSessionName(session.key);
const isCurrent = session.key === sessionId;
return (
<div
key={session.key}
onClick={() => handleSelectSession(session.key)}
className={`group flex cursor-pointer items-center justify-between rounded-xl px-4 py-3 text-[15px] transition-colors ${
session.key === sessionId
key={`${variant}:${session.key}`}
className={`group flex items-center gap-1 rounded-xl px-2 py-1 text-[15px] transition-colors ${
isCurrent
? 'bg-[#EFEEED] text-foreground'
: 'text-foreground hover:bg-[#EFEEED]/70'
: 'text-foreground hover:bg-[#EFEEED]/70 focus-within:bg-[#EFEEED]/70'
}`}
>
<div className="truncate">
<span className="truncate">{formatSessionName(session.key)}</span>
</div>
<button
onClick={(event) => handleArchiveSession(session.key, event)}
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-opacity"
title={pickAppText(locale, '归档会话', 'Archive session')}
aria-label={pickAppText(locale, '归档会话', 'Archive session')}
<button
type="button"
onClick={() => handleSelectSession(session.key)}
className="flex h-11 min-w-0 flex-1 items-center rounded-lg px-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-current={isCurrent ? 'true' : undefined}
>
<Trash2 className="w-3.5 h-3.5" />
<span className="truncate">{sessionName}</span>
</button>
<button
type="button"
onClick={() => setArchiveTargetSessionId(session.key)}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-muted-foreground opacity-100 transition-colors hover:bg-white hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100"
title={pickAppText(locale, '归档会话', 'Archive session')}
aria-label={pickAppText(locale, `归档会话 ${sessionName}`, `Archive session ${sessionName}`)}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
</ScrollArea>
);
})}
</div>
</ScrollArea>
</>
);
return (
<div className="relative flex h-[calc(100dvh-4rem)] overflow-hidden bg-background">
<aside className="hidden w-[280px] shrink-0 flex-col border-r border-[#E6E1DE] bg-[#F7F6F5] md:flex">
{renderSessionSidebar('desktop')}
</aside>
{sessionDrawerOpen && (
<div className="fixed inset-x-0 bottom-0 top-16 z-40 md:hidden">
<button
type="button"
className="absolute inset-0 bg-black/30"
aria-label={pickAppText(locale, '关闭最近对话', 'Close recent chats')}
onClick={() => setSessionDrawerOpen(false)}
/>
<aside className="absolute bottom-0 left-0 top-0 flex w-[min(86vw,320px)] flex-col border-r border-[#E6E1DE] bg-[#F7F6F5] shadow-2xl">
{renderSessionSidebar('drawer')}
</aside>
</div>
)}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex min-h-14 items-center gap-2 border-b border-[#E6E1DE] bg-[#F7F6F5] px-3 md:hidden">
<button
type="button"
onClick={() => setSessionDrawerOpen(true)}
className="flex h-11 w-11 items-center justify-center rounded-full border border-[#E6E1DE] bg-white text-[#1D1715]"
aria-label={pickAppText(locale, '打开最近对话', 'Open recent chats')}
>
<Menu className="h-5 w-5" />
</button>
<div className="min-w-0 text-sm font-medium text-foreground">
<span className="block truncate">{formatSessionName(sessionId)}</span>
</div>
</div>
<div className="flex-1 min-h-0">
<ChatWorkbench
messages={messages}
@ -614,14 +702,14 @@ export default function ChatPage() {
/>
</div>
<div className="bg-background px-8 pb-8 pt-4">
<div className="bg-background px-3 pb-4 pt-3 sm:px-5 sm:pb-6 md:px-8 md:pb-8 md:pt-4">
<div className="mx-auto max-w-5xl">
{(activeTask || revisionTargetRunId) && (
<div className="mb-2 flex">
{activeTask ? (
<Link
href={`/tasks/${encodeURIComponent(activeTask.task_id)}`}
className="inline-flex max-w-full items-center gap-2 rounded-full border border-[#D8D2CE] bg-[#F7F6F5] px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-[#EFEEED]"
className="inline-flex h-11 max-w-full items-center gap-2 rounded-full border border-[#D8D2CE] bg-[#F7F6F5] px-3 text-xs text-foreground transition-colors hover:bg-[#EFEEED]"
title={activeTask.description}
>
<span className="shrink-0 text-muted-foreground">
@ -638,7 +726,7 @@ export default function ChatPage() {
{pendingFiles.length > 0 && (
<div className="mb-2 space-y-1">
{pendingFiles.map((item, index) => (
<div key={`${item.file.name}:${index}`} className="flex items-center gap-2 px-3 py-1.5 bg-muted rounded-md text-sm">
<div key={`${item.file.name}:${index}`} className="flex min-h-11 items-center gap-2 rounded-md bg-muted px-3 py-1.5 text-sm">
<span className="truncate flex-1">
{item.file.name}{' '}
<span className="text-muted-foreground">({(item.file.size / 1024).toFixed(0)}KB)</span>
@ -652,8 +740,13 @@ export default function ChatPage() {
) : (
<span className="text-[#657162] text-xs">{pickAppText(locale, '就绪', 'Ready')}</span>
)}
<button onClick={() => removePendingFile(item.file)} className="text-muted-foreground hover:text-foreground">
<X className="w-3.5 h-3.5" />
<button
type="button"
onClick={() => removePendingFile(item.file)}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-background hover:text-foreground"
aria-label={pickAppText(locale, `移除附件 ${item.file.name}`, `Remove attachment ${item.file.name}`)}
>
<X className="h-4 w-4" />
</button>
</div>
))}
@ -662,8 +755,14 @@ export default function ChatPage() {
<div className="relative rounded-[28px] border border-[#E6E1DE] bg-white p-4 shadow-[0_8px_24px_rgba(0,0,0,0.08)]">
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
<label htmlFor="chat-composer" className="sr-only">
{revisionTargetRunId
? pickAppText(locale, '修改要求', 'Revision request')
: pickAppText(locale, '消息内容', 'Message content')}
</label>
<textarea
id="chat-composer"
ref={textareaRef}
value={input}
onChange={(e) => {
@ -677,7 +776,7 @@ export default function ChatPage() {
: pickAppText(locale, '今天想聊什么?', 'What would you like to talk about today?')
}
rows={1}
className="block w-full resize-none border-0 bg-transparent px-2 pb-8 pt-1 text-[17px] leading-7 placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
className="block w-full resize-none border-0 bg-transparent px-1 pb-8 pt-1 text-[16px] leading-7 placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 sm:px-2 sm:text-[17px]"
style={{ minHeight: '72px', maxHeight: '200px' }}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
@ -687,18 +786,20 @@ export default function ChatPage() {
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-5 text-[15px] text-muted-foreground">
<div className="flex items-center gap-2 text-[15px] text-muted-foreground sm:gap-5">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-2 text-foreground transition-colors hover:text-muted-foreground"
className="inline-flex h-11 w-11 items-center justify-center rounded-full text-foreground transition-colors hover:bg-[#F7F5F4] hover:text-muted-foreground"
title={pickAppText(locale, '添加附件', 'Add attachment')}
aria-label={pickAppText(locale, '添加附件', 'Add attachment')}
>
<Plus className="h-5 w-5" />
</button>
<button
type="button"
onClick={toggleThinkingMode}
className={`inline-flex h-8 items-center gap-2 rounded-full border px-3 text-sm transition-colors ${
className={`inline-flex h-11 items-center gap-2 rounded-full border px-3 text-sm transition-colors ${
thinkingModeEnabled
? 'border-primary/40 bg-[#F1EFEE] text-foreground'
: 'border-[#E6E1DE] bg-white text-muted-foreground hover:text-foreground'
@ -729,7 +830,43 @@ export default function ChatPage() {
</div>
</div>
{sessionProgressView && <CurrentSessionProgressSidebar view={sessionProgressView} />}
<Dialog open={Boolean(archiveTargetSessionId)} onOpenChange={(open) => !open && setArchiveTargetSessionId(null)}>
<DialogContent className="max-w-[calc(100vw-2rem)] sm:max-w-md">
<DialogHeader>
<DialogTitle>{pickAppText(locale, '归档此会话?', 'Archive this chat?')}</DialogTitle>
<DialogDescription>
{pickAppText(
locale,
archiveTargetSessionName ? `会话「${archiveTargetSessionName}」会从最近对话中移除。` : '此会话会从最近对话中移除。',
archiveTargetSessionName ? `Chat "${archiveTargetSessionName}" will be removed from recent chats.` : 'This chat will be removed from recent chats.'
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-2">
<button
type="button"
onClick={() => setArchiveTargetSessionId(null)}
className="h-11 rounded-md border border-border px-4 text-sm text-muted-foreground hover:bg-accent"
>
{pickAppText(locale, '取消', 'Cancel')}
</button>
<button
type="button"
onClick={() => archiveTargetSessionId && void handleArchiveSession(archiveTargetSessionId)}
className="h-11 rounded-md bg-destructive px-4 text-sm font-medium text-destructive-foreground hover:bg-destructive/90"
>
{pickAppText(locale, '确认归档', 'Confirm archive')}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
{activeTaskDetail ? (
<CurrentSessionProgressSidebar
cards={activeTaskTimelineView?.cards ?? []}
isLive={Boolean(activeTaskDetail.is_open && wsStatus === 'connected')}
/>
) : null}
</div>
);
}

View File

@ -1,6 +1,7 @@
'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import {
AlertCircle,
BarChart3,
@ -77,13 +78,25 @@ import { containedJsonTextClass, containedLongTextClass } from '@/lib/text-wrapp
const TERMINAL_DRAFT_STATUSES = new Set(['rejected', 'published', 'disabled', 'archived']);
const REJECTABLE_DRAFT_STATUSES = new Set(['draft', 'in_review', 'approved']);
type SkillsTab = 'published' | 'candidates' | 'drafts';
function normalizeSkillsTab(value: string | null | undefined): SkillsTab {
if (value === 'candidates' || value === 'drafts') {
return value;
}
return 'published';
}
export default function SkillsPage() {
const { locale } = useAppI18n();
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const [skills, setSkills] = useState<Skill[]>([]);
const [candidates, setCandidates] = useState<SkillLearningCandidate[]>([]);
const [drafts, setDrafts] = useState<SkillDraft[]>([]);
const [activeTab, setActiveTab] = useState<SkillsTab>(() => normalizeSkillsTab(searchParams?.get('tab')));
const [loading, setLoading] = useState(true);
const [actionId, setActionId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@ -119,6 +132,23 @@ export default function SkillsPage() {
void load();
}, [load]);
useEffect(() => {
setActiveTab(normalizeSkillsTab(searchParams?.get('tab')));
}, [searchParams]);
const changeTab = (value: string) => {
const nextTab = normalizeSkillsTab(value);
setActiveTab(nextTab);
const nextParams = new URLSearchParams(searchParams?.toString());
if (nextTab === 'published') {
nextParams.delete('tab');
} else {
nextParams.set('tab', nextTab);
}
const query = nextParams.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
};
const runAction = async (id: string, action: () => Promise<unknown>) => {
setActionId(id);
setError(null);
@ -193,18 +223,18 @@ export default function SkillsPage() {
}
return (
<div className="mx-auto max-w-6xl space-y-6 bg-white p-6 text-black [--background:0_0%_100%] [--card:0_0%_100%] [--card-foreground:0_0%_0%] [--foreground:0_0%_0%] [--muted-foreground:0_0%_0%] [--popover:0_0%_100%] [--popover-foreground:0_0%_0%] [--secondary-foreground:0_0%_0%]">
<div className="mx-auto w-full max-w-6xl space-y-6 overflow-x-hidden bg-white px-4 py-6 text-black [--background:0_0%_100%] [--card:0_0%_100%] [--card-foreground:0_0%_0%] [--foreground:0_0%_0%] [--muted-foreground:0_0%_0%] [--popover:0_0%_100%] [--popover-foreground:0_0%_0%] [--secondary-foreground:0_0%_0%] sm:px-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="flex items-center gap-2 text-2xl font-bold">
<Puzzle className="w-6 h-6" />
{t('技能', 'Skills')}
</h1>
<div className="flex items-center gap-2">
<Button onClick={() => void load()} variant="outline" size="sm">
<div className="flex flex-wrap items-center gap-2">
<Button onClick={() => void load()} variant="outline" size="sm" className="h-11">
<RefreshCw className="mr-2 h-4 w-4" />
{t('刷新', 'Refresh')}
</Button>
<Button onClick={() => setShowUpload(true)} size="sm">
<Button onClick={() => setShowUpload(true)} size="sm" className="h-11">
<Upload className="mr-2 h-4 w-4" />
{t('上传技能', 'Upload skill')}
</Button>
@ -238,6 +268,7 @@ export default function SkillsPage() {
<Button
variant="ghost"
size="sm"
className="h-11"
onClick={() => {
setSelectedSkillName(null);
setSkillDetail(null);
@ -277,19 +308,20 @@ export default function SkillsPage() {
}
actions={
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => downloadSkill(skillDetail.skill.name).catch((err) => setError(err.message))}>
<Button variant="outline" size="sm" className="h-11" onClick={() => downloadSkill(skillDetail.skill.name).catch((err) => setError(err.message))}>
<Download className="mr-2 h-4 w-4" />
{t('下载', 'Download')}
</Button>
{skillDetail.skill.source === 'workspace' && (
<>
<Button variant="outline" size="sm" onClick={() => onRollbackSkill(skillDetail.skill.name)}>
<Button variant="outline" size="sm" className="h-11" onClick={() => onRollbackSkill(skillDetail.skill.name)}>
<RefreshCw className="mr-2 h-4 w-4" />
{t('回滚', 'Rollback')}
</Button>
<Button
variant="outline"
size="sm"
className="h-11"
disabled={Boolean(actionId)}
onClick={() => void runAction(`disable:${skillDetail.skill.name}`, () => disablePublishedSkill(skillDetail.skill.name, t('人工禁用', 'Manual disable')))}
>
@ -299,7 +331,7 @@ export default function SkillsPage() {
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
className="h-11 text-destructive hover:text-destructive"
disabled={Boolean(actionId)}
onClick={() => void runAction(`delete:${skillDetail.skill.name}`, () => deleteSkill(skillDetail.skill.name)).then(() => {
setSelectedSkillName(null);
@ -330,14 +362,14 @@ export default function SkillsPage() {
)}
{!selectedSkillName && (
<Tabs defaultValue="published" className="space-y-4">
<TabsList>
<TabsTrigger value="published">{t('已发布', 'Published')}</TabsTrigger>
<TabsTrigger value="candidates">{t('候选', 'Candidates')}</TabsTrigger>
<TabsTrigger value="drafts">{t('草稿评审', 'Draft review')}</TabsTrigger>
<Tabs value={activeTab} onValueChange={changeTab} className="min-w-0 space-y-4">
<TabsList className="h-auto min-h-11 w-full max-w-full justify-start overflow-x-auto sm:w-auto">
<TabsTrigger value="published" className="h-10">{t('已发布', 'Published')}</TabsTrigger>
<TabsTrigger value="candidates" className="h-10">{t('候选', 'Candidates')}</TabsTrigger>
<TabsTrigger value="drafts" className="h-10">{t('草稿评审', 'Draft review')}</TabsTrigger>
</TabsList>
<TabsContent value="published">
<TabsContent value="published" className="min-w-0">
<PublishedSkillsTable
skills={skills}
onOpen={(name) => void openSkillDetail(name)}
@ -350,7 +382,7 @@ export default function SkillsPage() {
/>
</TabsContent>
<TabsContent value="candidates">
<TabsContent value="candidates" className="min-w-0">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('学习候选', 'Learning candidates')}</CardTitle>
@ -384,7 +416,7 @@ export default function SkillsPage() {
</Card>
</TabsContent>
<TabsContent value="drafts">
<TabsContent value="drafts" className="min-w-0">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('草稿、评审与发布', 'Drafts, review, and publish')}</CardTitle>
@ -473,6 +505,54 @@ function PublishedSkillsTable({
{skills.length === 0 ? (
<EmptyState icon={<Puzzle className="h-8 w-8" />} text={t('暂无技能', 'No skills yet')} />
) : (
<>
<div className="space-y-3 p-3 md:hidden">
{skills.map((skill) => (
<div key={`${skill.source}:${skill.name}:card`} className="min-w-0 rounded-lg border border-border bg-white p-4">
<button
type="button"
className="block min-h-11 w-full text-left"
onClick={() => onOpen(skill.name)}
>
<div className={`text-sm font-semibold ${containedLongTextClass}`}>{skill.name}</div>
<div className={`mt-1 text-sm leading-5 text-muted-foreground ${containedLongTextClass}`}>
{skill.description || '-'}
</div>
</button>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant={skill.source === 'builtin' ? 'secondary' : 'default'} className="text-xs">
{skill.source === 'builtin' ? t('内置', 'Built in') : t('工作区', 'Workspace')}
</Badge>
<Badge variant={skill.available ? 'default' : 'outline'} className="text-xs">
{skill.available ? t('可用', 'Available') : t('不可用', 'Unavailable')}
</Badge>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button variant="outline" size="sm" className="h-11" onClick={() => onDownload(skill.name)}>
<Download className="mr-2 h-4 w-4" />
{t('下载', 'Download')}
</Button>
{skill.source === 'workspace' && (
<>
<Button variant="outline" size="sm" className="h-11" onClick={() => onRollback(skill.name)}>
<RefreshCw className="mr-2 h-4 w-4" />
{t('回滚', 'Rollback')}
</Button>
<Button variant="outline" size="sm" className="h-11" onClick={() => onDisable(skill.name)}>
<ShieldCheck className="mr-2 h-4 w-4" />
{t('禁用', 'Disable')}
</Button>
<Button variant="outline" size="sm" className="h-11 text-destructive hover:text-destructive" onClick={() => onDelete(skill.name)}>
<Trash2 className="mr-2 h-4 w-4" />
{t('删除', 'Delete')}
</Button>
</>
)}
</div>
</div>
))}
</div>
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
@ -490,7 +570,7 @@ function PublishedSkillsTable({
className="cursor-pointer"
onClick={() => onOpen(skill.name)}
>
<TableCell className="font-medium">{skill.name}</TableCell>
<TableCell className={`font-medium ${containedLongTextClass}`}>{skill.name}</TableCell>
<TableCell>
<span className="block max-w-[360px] truncate text-sm text-muted-foreground">
{skill.description}
@ -508,7 +588,14 @@ function PublishedSkillsTable({
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={(event) => { event.stopPropagation(); onDownload(skill.name); }}>
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
aria-label={t('下载', 'Download')}
title={t('下载', 'Download')}
onClick={(event) => { event.stopPropagation(); onDownload(skill.name); }}
>
<Download className="h-3.5 w-3.5" />
</Button>
{skill.source === 'workspace' && (
@ -516,7 +603,9 @@ function PublishedSkillsTable({
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-11 w-11"
aria-label={t('回滚', 'Rollback')}
title={t('回滚', 'Rollback')}
onClick={(event) => {
event.stopPropagation();
onRollback(skill.name);
@ -527,7 +616,9 @@ function PublishedSkillsTable({
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-11 w-11"
aria-label={t('禁用', 'Disable')}
title={t('禁用', 'Disable')}
onClick={(event) => {
event.stopPropagation();
onDisable(skill.name);
@ -538,7 +629,9 @@ function PublishedSkillsTable({
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
className="h-11 w-11 text-destructive hover:text-destructive"
aria-label={t('删除', 'Delete')}
title={t('删除', 'Delete')}
onClick={(event) => {
event.stopPropagation();
onDelete(skill.name);
@ -554,6 +647,8 @@ function PublishedSkillsTable({
))}
</TableBody>
</Table>
</div>
</>
)}
</CardContent>
</Card>
@ -590,7 +685,7 @@ function CandidateCard({
: null;
return (
<div className="rounded-lg border border-border bg-white p-4">
<div className="min-w-0 max-w-full rounded-lg border border-border bg-white p-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-2">
@ -607,7 +702,7 @@ function CandidateCard({
<div>
<h3 className="break-words text-base font-semibold tracking-normal">{title}</h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
<p className={`mt-1 text-sm leading-6 text-muted-foreground ${containedLongTextClass}`}>
{candidate.reason || t('没有提供候选理由。', 'No candidate reason was provided.')}
</p>
</div>
@ -656,7 +751,7 @@ function CandidateCard({
)}
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="font-mono">{candidate.candidate_id}</span>
<span className={`font-mono ${containedLongTextClass}`}>{candidate.candidate_id}</span>
{String(evidence.task_id || '') && <span>{t('任务', 'Task')}: {String(evidence.task_id)}</span>}
{String(evidence.skill_version || '') && <span>{t('基线版本', 'Base version')}: {String(evidence.skill_version)}</span>}
{candidate.created_at && <span>{t('创建于', 'Created')}: {formatDateTime(candidate.created_at)}</span>}
@ -666,11 +761,12 @@ function CandidateCard({
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={Boolean(actionId)} onClick={onIgnore}>
<Button size="sm" variant="outline" className="h-11" disabled={Boolean(actionId)} onClick={onIgnore}>
{t('忽略', 'Ignore')}
</Button>
<Button
size="sm"
className="h-11"
disabled={Boolean(actionId)}
onClick={() => void onSynthesize()}
>
@ -685,6 +781,7 @@ function CandidateCard({
<Button
size="sm"
variant="outline"
className="h-11"
disabled={Boolean(actionId)}
onClick={() => void onRegenerate()}
>
@ -753,7 +850,7 @@ function DraftCard({
void onPublish(isHighRisk);
};
return (
<div className="rounded-lg border border-border bg-white p-4">
<div className="min-w-0 max-w-full rounded-lg border border-border bg-white p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
@ -776,9 +873,9 @@ function DraftCard({
</div>
<div className="mt-2">
<div className="text-xs font-medium text-muted-foreground">{t('技能名', 'Skill name')}</div>
<h3 className="break-words text-lg font-semibold tracking-normal">{draft.skill_name}</h3>
<h3 className={`text-lg font-semibold tracking-normal ${containedLongTextClass}`}>{draft.skill_name}</h3>
</div>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
<p className={`mt-1 text-sm leading-6 text-muted-foreground ${containedLongTextClass}`}>
{draft.reason || description || t('没有提供草稿说明。', 'No draft notes were provided.')}
</p>
<div className="mt-3 grid gap-3 md:grid-cols-3">
@ -805,37 +902,37 @@ function DraftCard({
</div>
)}
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="font-mono">{draft.skill_name}/{draft.draft_id}</span>
<span className={`font-mono ${containedLongTextClass}`}>{draft.skill_name}/{draft.draft_id}</span>
<span>{t('创建者', 'Author')}: {draft.created_by}</span>
<span>{t('创建于', 'Created')}: {formatDateTime(draft.created_at)}</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" disabled={busy || submitBlocked} onClick={() => void onSubmit()}>
<Button variant="outline" size="sm" className="h-11" disabled={busy || submitBlocked} onClick={() => void onSubmit()}>
<Send className="mr-2 h-4 w-4" />
{t('送审', 'Submit')}
</Button>
<Button variant="outline" size="sm" disabled={busy || approveBlocked} onClick={() => void onApprove()}>
<Button variant="outline" size="sm" className="h-11" disabled={busy || approveBlocked} onClick={() => void onApprove()}>
<Check className="mr-2 h-4 w-4" />
{t('批准', 'Approve')}
</Button>
<Button variant="outline" size="sm" disabled={busy || rejectBlocked} onClick={() => void onReject()}>
<Button variant="outline" size="sm" className="h-11" disabled={busy || rejectBlocked} onClick={() => void onReject()}>
<XCircle className="mr-2 h-4 w-4" />
{t('拒绝', 'Reject')}
</Button>
<Button variant="outline" size="sm" disabled={busy || TERMINAL_DRAFT_STATUSES.has(draft.status)} onClick={() => void onRecheckSafety()}>
<Button variant="outline" size="sm" className="h-11" disabled={busy || TERMINAL_DRAFT_STATUSES.has(draft.status)} onClick={() => void onRecheckSafety()}>
<ShieldCheck className="mr-2 h-4 w-4" />
{t('复检', 'Recheck')}
</Button>
<Button size="sm" disabled={busy || publishBlocked} onClick={handlePublish}>
<Button size="sm" className="h-11" disabled={busy || publishBlocked} onClick={handlePublish}>
<Rocket className="mr-2 h-4 w-4" />
{t('发布', 'Publish')}
</Button>
</div>
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="rounded-md border border-border bg-muted/20 p-4">
<div className="mt-4 grid min-w-0 gap-3 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="min-w-0 max-w-full rounded-md border border-border bg-muted/20 p-3 sm:p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-medium">
<FileText className="h-4 w-4 text-muted-foreground" />
@ -858,7 +955,7 @@ function DraftCard({
)}
</div>
<div className="space-y-3">
<div className="min-w-0 space-y-3">
<GateSummary
title={t('发布门禁', 'Publish gates')}
summary={canPublishLabel}
@ -883,7 +980,7 @@ function DraftCard({
</div>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<div className="mt-3 grid min-w-0 gap-3 md:grid-cols-2">
<SafetyReportPanel report={safety} />
<EvalReportPanel report={evalReport} />
</div>
@ -905,7 +1002,7 @@ function SafetyReportPanel({ report }: { report?: SkillDraftSafetyReport | null
}
const problems = [...(report.blocked_reasons || []), ...(report.issues || [])];
return (
<div className="rounded-md border border-border bg-muted/20 p-4">
<div className="min-w-0 rounded-md border border-border bg-muted/20 p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-medium">
{report.passed ? <ShieldCheck className="h-4 w-4 text-muted-foreground" /> : <ShieldAlert className="h-4 w-4 text-destructive" />}
@ -922,7 +1019,7 @@ function SafetyReportPanel({ report }: { report?: SkillDraftSafetyReport | null
{problems.map((item, index) => (
<li key={`${item}:${index}`} className="flex gap-2">
<AlertCircle className="mt-1 h-3.5 w-3.5 shrink-0 text-destructive" />
<span>{item}</span>
<span className={containedLongTextClass}>{item}</span>
</li>
))}
</ul>
@ -933,7 +1030,7 @@ function SafetyReportPanel({ report }: { report?: SkillDraftSafetyReport | null
</p>
)}
{report.suggested_fix && (
<p className="mt-3 rounded-md border border-border bg-white p-3 text-sm">
<p className={`mt-3 rounded-md border border-border bg-white p-3 text-sm ${containedLongTextClass}`}>
<span className="font-medium">{t('建议处理', 'Suggested fix')}:</span> {report.suggested_fix}
</p>
)}
@ -957,7 +1054,7 @@ function EvalReportPanel({ report }: { report?: SkillDraftEvalReport | null }) {
}
if (report.status === 'skipped_provider_unavailable') {
return (
<div className="rounded-md border border-border bg-muted/20 p-4">
<div className="min-w-0 rounded-md border border-border bg-muted/20 p-4">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<BarChart3 className="h-4 w-4 text-muted-foreground" />
{t('评估报告', 'Eval report')}
@ -970,7 +1067,7 @@ function EvalReportPanel({ report }: { report?: SkillDraftEvalReport | null }) {
);
}
return (
<div className="rounded-md border border-border bg-muted/20 p-4">
<div className="min-w-0 rounded-md border border-border bg-muted/20 p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-medium">
<BarChart3 className="h-4 w-4 text-muted-foreground" />
@ -1002,7 +1099,19 @@ function EvalReportPanel({ report }: { report?: SkillDraftEvalReport | null }) {
<div className="border-b border-border px-3 py-2 text-xs font-medium text-muted-foreground">
{t('回放案例', 'Replay cases')}
</div>
<div className="max-h-48 overflow-auto">
<div className="space-y-2 p-3 md:hidden">
{report.cases.map((item, index) => (
<div key={`${String(item.run_id || index)}:${index}:card`} className="rounded-md border border-border bg-muted/20 p-3 text-xs">
<div className={`font-mono ${containedLongTextClass}`}>{String(item.run_id || '-')}</div>
<div className="mt-2 grid grid-cols-3 gap-2">
<MetricTile label={t('基线', 'Baseline')} value={formatScore(toNumber(item.baseline_score))} />
<MetricTile label={t('候选', 'Candidate')} value={formatScore(toNumber(item.candidate_score))} />
<MetricTile label={t('变化', 'Delta')} value={formatSignedScore(toNumber(item.delta))} />
</div>
</div>
))}
</div>
<div className="hidden max-h-48 overflow-auto md:block">
<table className="w-full text-left text-xs">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
@ -1042,7 +1151,7 @@ function GateSummary({
items: Array<{ label: string; ok: boolean }>;
}) {
return (
<div className="rounded-md border border-border bg-muted/20 p-4">
<div className="min-w-0 rounded-md border border-border bg-muted/20 p-4">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<ListChecks className="h-4 w-4 text-muted-foreground" />
{title}
@ -1070,7 +1179,7 @@ function ReadablePanel({
empty: string;
}) {
return (
<div className="rounded-md border border-border bg-muted/20 p-4">
<div className="min-w-0 rounded-md border border-border bg-muted/20 p-4">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
{icon}
{title}
@ -1090,7 +1199,7 @@ function ReadableFact({
value: string;
}) {
return (
<div className="rounded-md border border-border bg-white p-3">
<div className="min-w-0 rounded-md border border-border bg-white p-3">
<div className="mb-1 flex items-center gap-2 text-xs font-medium text-muted-foreground">
{icon}
{label}
@ -1111,7 +1220,7 @@ function MetricTile({
}) {
const toneClass = tone === 'bad' ? 'text-destructive' : 'text-foreground';
return (
<div className="rounded-md border border-border bg-white p-3">
<div className="min-w-0 rounded-md border border-border bg-white p-3">
<div className="text-xs font-medium text-muted-foreground">{label}</div>
<div className={`mt-1 text-lg font-semibold ${toneClass}`}>{value}</div>
</div>
@ -1121,7 +1230,7 @@ function MetricTile({
function RawDetails({ title, payload }: { title: string; payload: unknown }) {
return (
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-white">
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-xs font-medium text-muted-foreground">
<summary className="flex h-11 cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-xs font-medium text-muted-foreground">
{title}
<ChevronDown className="h-3.5 w-3.5" />
</summary>
@ -1134,7 +1243,7 @@ function RawDetails({ title, payload }: { title: string; payload: unknown }) {
function MarkdownPreview({ content }: { content: string }) {
return (
<div className="prose prose-sm max-w-none text-black prose-a:text-black prose-code:rounded prose-code:bg-white prose-code:px-1 prose-code:py-0.5 prose-code:text-black prose-headings:text-black prose-headings:tracking-normal prose-li:text-black prose-p:text-black prose-pre:border prose-pre:border-border prose-pre:bg-white prose-pre:text-black prose-strong:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<div className={`prose prose-sm max-w-none text-black prose-a:text-black prose-code:rounded prose-code:bg-white prose-code:px-1 prose-code:py-0.5 prose-code:text-black prose-headings:text-black prose-headings:tracking-normal prose-li:text-black prose-p:text-black prose-pre:border prose-pre:border-border prose-pre:bg-white prose-pre:text-black prose-strong:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_*]:min-w-0 ${containedLongTextClass}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);

View File

@ -21,6 +21,7 @@ import {
getChannelConfig,
getChannelConnectorSession,
getStatus,
listChannelConnections,
listChannelConnectors,
listChannelEvents,
restartRuntime,
@ -54,6 +55,7 @@ import { Textarea } from '@/components/ui/textarea';
import type {
ChannelConfigDetail,
ChannelConnectorDescriptor,
ChannelConnectionView,
ChannelEventRecord,
ChannelStatus,
ConnectorSessionResponse,
@ -62,6 +64,7 @@ import type {
} from '@/types';
import { AppLocale, pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { connectorChannelForKind, visibleConnectorCards } from '@/lib/channel-connector-state';
type ProviderFormState = {
enabled: boolean;
@ -137,6 +140,8 @@ const EMPTY_CHANNEL_FORM: ChannelFormState = {
const CONFIGURABLE_CHANNEL_KINDS = new Set(['telegram', 'feishu', 'qqbot', 'weixin']);
const SESSION_CONNECTOR_KINDS = new Set(['weixin', 'feishu']);
const VISIBLE_PROVIDER_IDS = new Set(['openai', 'deepseek', 'dashscope', 'vllm']);
const LOCAL_CONNECTOR_KINDS = new Set(['terminal']);
type ConnectorWizardForm = {
kind: string;
@ -146,6 +151,12 @@ type ConnectorWizardForm = {
appId: string;
appSecret: string;
verificationToken: string;
requireMentionInGroups: boolean;
respondToMentionAll: boolean;
dmMode: 'open' | 'allowlist' | 'pair' | 'disabled';
allowFrom: string;
groupAllowFrom: string;
maxMessageChars: string;
};
const EMPTY_CONNECTOR_WIZARD: ConnectorWizardForm = {
@ -156,6 +167,12 @@ const EMPTY_CONNECTOR_WIZARD: ConnectorWizardForm = {
appId: '',
appSecret: '',
verificationToken: '',
requireMentionInGroups: true,
respondToMentionAll: false,
dmMode: 'open',
allowFrom: '',
groupAllowFrom: '',
maxMessageChars: '20000',
};
export default function StatusPage() {
@ -193,6 +210,7 @@ export default function StatusPage() {
const [restarting, setRestarting] = useState(false);
const [restartError, setRestartError] = useState<string | null>(null);
const [connectors, setConnectors] = useState<ChannelConnectorDescriptor[]>([]);
const [channelConnections, setChannelConnections] = useState<ChannelConnectionView[]>([]);
const [loadingConnectors, setLoadingConnectors] = useState(false);
const [connectorDialogOpen, setConnectorDialogOpen] = useState(false);
const [connectorForm, setConnectorForm] = useState<ConnectorWizardForm>(() => ({ ...EMPTY_CONNECTOR_WIZARD }));
@ -234,8 +252,17 @@ export default function StatusPage() {
}
};
const loadChannelConnections = async () => {
try {
setChannelConnections(await listChannelConnections());
} catch {
setChannelConnections([]);
}
};
useEffect(() => {
loadConnectors();
void loadChannelConnections();
}, []);
useEffect(() => {
@ -329,15 +356,19 @@ export default function StatusPage() {
setChannelError(null);
setChannelRestartRequired(false);
setChannelEvents([]);
setLoadingChannelConfig(true);
setLoadingChannelConfig(channel.kind !== 'terminal');
setLoadingChannelEvents(true);
try {
const config = await getChannelConfig(channel.channel_id);
setChannelConfig(config);
setChannelForm(channelFormFromConfig(config));
} catch (err: any) {
setChannelError(err.message || pickAppText(locale, '加载通道配置失败', 'Failed to load channel configuration'));
} finally {
if (channel.kind !== 'terminal') {
try {
const config = await getChannelConfig(channel.channel_id);
setChannelConfig(config);
setChannelForm(channelFormFromConfig(config));
} catch (err: any) {
setChannelError(err.message || pickAppText(locale, '加载通道配置失败', 'Failed to load channel configuration'));
} finally {
setLoadingChannelConfig(false);
}
} else {
setLoadingChannelConfig(false);
}
try {
@ -396,6 +427,11 @@ export default function StatusPage() {
});
};
const openLocalConnectorDetails = (kind: string, channel?: ChannelStatus) => {
if (kind !== 'terminal') return;
void openChannelDetails(channel || terminalFallbackChannel(locale));
};
const handleStartConnectorSession = async () => {
if (!connectorForm.kind || !SESSION_CONNECTOR_KINDS.has(connectorForm.kind)) return;
setStartingConnector(true);
@ -405,6 +441,14 @@ export default function StatusPage() {
if (connectorForm.kind === 'feishu') {
options.domain = connectorForm.domain || 'feishu';
options.mode = connectorForm.mode;
options.requireMentionInGroups = connectorForm.requireMentionInGroups;
options.respondToMentionAll = connectorForm.respondToMentionAll;
options.dmMode = connectorForm.dmMode;
const allowFrom = parseList(connectorForm.allowFrom);
const groupAllowFrom = parseList(connectorForm.groupAllowFrom);
if (allowFrom.length) options.allowFrom = allowFrom;
if (groupAllowFrom.length) options.groupAllowFrom = groupAllowFrom;
if (connectorForm.maxMessageChars.trim()) options.maxMessageChars = Number(connectorForm.maxMessageChars.trim());
if (connectorForm.appId.trim()) options.appId = connectorForm.appId.trim();
if (connectorForm.appSecret.trim()) options.appSecret = connectorForm.appSecret.trim();
if (connectorForm.verificationToken.trim()) options.verificationToken = connectorForm.verificationToken.trim();
@ -415,6 +459,10 @@ export default function StatusPage() {
options,
});
setConnectorSession(response);
if (response.session.status === 'connected') {
await loadStatus();
await loadChannelConnections();
}
if (!connectorSessionDone(response.session.status)) {
window.setTimeout(() => {
void pollConnectorSession(response.session.sessionId);
@ -434,6 +482,7 @@ export default function StatusPage() {
setConnectorSession(response);
if (response.session.status === 'connected') {
await loadStatus();
await loadChannelConnections();
}
} catch (err: any) {
setConnectorError(err.message || pickAppText(locale, '刷新连接状态失败', 'Failed to refresh connector status'));
@ -452,14 +501,14 @@ export default function StatusPage() {
if (error) {
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mx-auto max-w-4xl p-4 sm:p-6">
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center gap-3 text-destructive">
<AlertCircle className="w-5 h-5" />
<div>
<div className="flex items-start gap-3 text-destructive">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0" />
<div className="min-w-0">
<p className="font-medium">{pickAppText(locale, '无法连接到 Boardware Agent Sandbox 后端', 'Unable to connect to the Boardware Agent Sandbox backend')}</p>
<p className="text-sm text-muted-foreground mt-1">{error}</p>
<p className="mt-1 break-words text-sm text-muted-foreground">{error}</p>
<p className="text-sm text-muted-foreground mt-1">{pickAppText(locale, '请确认后端服务已启动,并且当前页面可以访问它。', 'Please confirm the backend service is running and reachable from this page.')}</p>
</div>
</div>
@ -475,8 +524,11 @@ export default function StatusPage() {
if (!status) return null;
const visibleProviders = status.providers.filter(visibleProvider);
const connectorCards = visibleConnectorCards(connectors);
return (
<div className="mx-auto max-w-6xl p-6 space-y-6">
<div className="mx-auto max-w-6xl space-y-6 p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-bold">{pickAppText(locale, '配置', 'Settings')}</h1>
@ -575,7 +627,7 @@ export default function StatusPage() {
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-destructive">{agentError || ''}</div>
<div className="min-w-0 break-words text-sm text-destructive">{agentError || ''}</div>
<Button onClick={handleSaveAgentConfig} disabled={savingAgent} className="sm:self-end">
{savingAgent ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{pickAppText(locale, '保存智能体配置', 'Save agent config')}
@ -594,13 +646,13 @@ export default function StatusPage() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
{status.providers.map((p) => (
{visibleProviders.map((p) => (
<button
key={p.id || p.name}
type="button"
onClick={() => openProviderDialog(p)}
className={[
'group flex min-h-[76px] w-full items-start justify-between rounded-lg border p-3 text-left transition',
'group flex min-h-[76px] w-full items-start justify-between gap-3 rounded-lg border p-3 text-left transition',
p.active
? 'border-primary bg-primary/5 shadow-sm'
: 'border-border bg-background hover:border-primary/50 hover:bg-muted/40',
@ -613,11 +665,11 @@ export default function StatusPage() {
) : (
<XCircle className="h-4 w-4 shrink-0 text-muted-foreground/40" />
)}
<span className={p.has_key ? 'truncate' : 'truncate text-muted-foreground'}>
<span className={p.has_key ? 'break-all' : 'break-all text-muted-foreground'}>
{providerLabel(p)}
</span>
</span>
<span className="block truncate text-xs text-muted-foreground">
<span className="block break-words text-xs text-muted-foreground">
{p.active
? pickAppText(locale, '当前默认', 'Current default')
: p.enabled
@ -625,7 +677,7 @@ export default function StatusPage() {
: pickAppText(locale, '点击配置', 'Click to configure')}
</span>
{(p.detail || p.api_key_masked) && (
<span className="block truncate text-xs text-muted-foreground">
<span className="block break-all text-xs text-muted-foreground">
{p.api_key_masked || p.detail}
</span>
)}
@ -639,7 +691,7 @@ export default function StatusPage() {
<Dialog open={Boolean(selectedProvider)} onOpenChange={(open) => !open && setSelectedProvider(null)}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogHeader className="pr-10">
<DialogTitle>
{pickAppText(locale, '配置提供商', 'Configure provider')}
{selectedProvider ? ` · ${providerLabel(selectedProvider)}` : ''}
@ -649,8 +701,8 @@ export default function StatusPage() {
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<div className="flex items-center justify-between gap-4 rounded-lg border px-3 py-2">
<div className="min-w-0">
<Label className="text-sm">{pickAppText(locale, '启用提供商', 'Enable provider')}</Label>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '关闭会从配置中移除这个提供商', 'Turning this off removes this provider from config')}
@ -709,7 +761,7 @@ export default function StatusPage() {
</div>
{providerError ? (
<p className="text-sm text-destructive">{providerError}</p>
<p className="break-words text-sm text-destructive">{providerError}</p>
) : null}
</div>
<DialogFooter>
@ -725,19 +777,22 @@ export default function StatusPage() {
</Dialog>
<Dialog open={Boolean(selectedChannel)} onOpenChange={(open) => !open && setSelectedChannel(null)}>
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-[820px]">
<DialogHeader>
<DialogTitle>{selectedChannel?.display_name || selectedChannel?.channel_id}</DialogTitle>
<DialogDescription>
<DialogContent className="sm:max-w-[820px]">
<DialogHeader className="pr-10">
<DialogTitle className="break-words leading-tight">{selectedChannel?.display_name || selectedChannel?.channel_id}</DialogTitle>
<DialogDescription className="break-all">
{selectedChannel ? `${selectedChannel.kind}/${selectedChannel.mode} · ${selectedChannel.channel_id}` : ''}
</DialogDescription>
</DialogHeader>
{selectedChannel ? (
<div className="space-y-5">
{selectedChannel.kind === 'terminal' ? (
<TerminalConnectionGuide channel={selectedChannel} locale={locale} />
) : null}
{CONFIGURABLE_CHANNEL_KINDS.has(channelForm.kind) ? (
<div className="space-y-5 rounded-lg border p-4">
<div className="min-w-0 space-y-5 rounded-lg border p-4">
<div className="flex items-center justify-between gap-4">
<div>
<div className="min-w-0">
<p className="text-sm font-medium">{pickAppText(locale, '连接配置', 'Connection settings')}</p>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '凭据留空会保留已保存的值。保存后重启实例才会重新连接通道。', 'Leave credentials blank to keep saved values. Restart the instance after saving to reconnect channels.')}
@ -751,15 +806,17 @@ export default function StatusPage() {
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label={pickAppText(locale, '显示名', 'Display name')}>
<Field id="channel-display-name" label={pickAppText(locale, '显示名', 'Display name')}>
<Input
id="channel-display-name"
value={channelForm.displayName}
onChange={(event) => setChannelForm((prev) => ({ ...prev, displayName: event.target.value }))}
placeholder={selectedChannel.display_name || selectedChannel.channel_id}
/>
</Field>
<Field label="Account ID">
<Field id="channel-account-id" label="Account ID">
<Input
id="channel-account-id"
value={channelForm.accountId}
onChange={(event) => setChannelForm((prev) => ({ ...prev, accountId: event.target.value }))}
placeholder="bot-main"
@ -810,11 +867,11 @@ export default function StatusPage() {
/>
<ChannelPolicyFields form={channelForm} locale={locale} setForm={setChannelForm} />
{channelError ? <p className="text-sm text-destructive">{channelError}</p> : null}
{channelError ? <p className="break-words text-sm text-destructive">{channelError}</p> : null}
{channelRestartRequired ? (
<div className="flex flex-col gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm sm:flex-row sm:items-center sm:justify-between">
<span>{pickAppText(locale, '配置已保存。重启实例后通道会按新配置启动。', 'Configuration saved. Restart the instance to start channels with the new settings.')}</span>
<Button variant="outline" size="sm" onClick={() => setRestartOpen(true)}>
<span className="min-w-0 break-words">{pickAppText(locale, '配置已保存。重启实例后通道会按新配置启动。', 'Configuration saved. Restart the instance to start channels with the new settings.')}</span>
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setRestartOpen(true)}>
<RefreshCw className="mr-2 h-4 w-4" />
{pickAppText(locale, '重启实例', 'Restart instance')}
</Button>
@ -844,11 +901,11 @@ export default function StatusPage() {
<div className="max-h-[320px] overflow-auto rounded-md border">
{channelEvents.map((event) => (
<div key={event.event_id} className="border-b px-3 py-2 text-xs last:border-b-0">
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{event.kind}</span>
<span className="text-muted-foreground">{event.created_at}</span>
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
<span className="break-all font-medium">{event.kind}</span>
<span className="break-all text-muted-foreground">{event.created_at}</span>
</div>
<div className="mt-1 text-muted-foreground">
<div className="mt-1 break-words text-muted-foreground">
{event.status}{event.error ? ` · ${event.error}` : ''}
</div>
</div>
@ -868,7 +925,7 @@ export default function StatusPage() {
<Dialog open={restartOpen} onOpenChange={setRestartOpen}>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogHeader className="pr-10">
<DialogTitle>{pickAppText(locale, '重启实例?', 'Restart instance?')}</DialogTitle>
<DialogDescription>
{pickAppText(
@ -878,7 +935,7 @@ export default function StatusPage() {
)}
</DialogDescription>
</DialogHeader>
{restartError ? <p className="text-sm text-destructive">{restartError}</p> : null}
{restartError ? <p className="break-words text-sm text-destructive">{restartError}</p> : null}
<DialogFooter>
<Button variant="outline" onClick={() => setRestartOpen(false)} disabled={restarting}>
{pickAppText(locale, '取消', 'Cancel')}
@ -898,8 +955,8 @@ export default function StatusPage() {
setConnectorError(null);
}
}}>
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-[560px]">
<DialogHeader>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader className="pr-10">
<DialogTitle>
{connectorForm.kind ? connectorDisplayName({ kind: connectorForm.kind }) : pickAppText(locale, '连接通道', 'Connect channel')}
</DialogTitle>
@ -913,8 +970,9 @@ export default function StatusPage() {
</DialogHeader>
<div className="space-y-5">
<Field label={pickAppText(locale, '显示名', 'Display name')}>
<Field id="connector-display-name" label={pickAppText(locale, '显示名', 'Display name')}>
<Input
id="connector-display-name"
value={connectorForm.displayName}
onChange={(event) => setConnectorForm((prev) => ({ ...prev, displayName: event.target.value }))}
disabled={Boolean(connectorSession) || startingConnector}
@ -961,6 +1019,75 @@ export default function StatusPage() {
))}
</div>
) : null}
<div className="space-y-4 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-sm font-medium">{pickAppText(locale, '群聊必须 @ Beaver', 'Require @ in groups')}</p>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '默认开启,避免群聊所有消息触发智能体。', 'Enabled by default to avoid processing every group message.')}
</p>
</div>
<Switch
checked={connectorForm.requireMentionInGroups}
onCheckedChange={(checked) => setConnectorForm((prev) => ({ ...prev, requireMentionInGroups: checked }))}
disabled={Boolean(connectorSession) || startingConnector}
/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-sm font-medium">{pickAppText(locale, '响应 @所有人', 'Respond to @all')}</p>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '默认关闭,避免群公告式消息触发。', 'Disabled by default to avoid broadcast-style triggers.')}
</p>
</div>
<Switch
checked={connectorForm.respondToMentionAll}
onCheckedChange={(checked) => setConnectorForm((prev) => ({ ...prev, respondToMentionAll: checked }))}
disabled={Boolean(connectorSession) || startingConnector}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label={pickAppText(locale, '私聊策略', 'DM policy')}>
<Select
value={connectorForm.dmMode}
onValueChange={(value) => setConnectorForm((prev) => ({ ...prev, dmMode: value as ConnectorWizardForm['dmMode'] }))}
disabled={Boolean(connectorSession) || startingConnector}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="open">{pickAppText(locale, '开放', 'Open')}</SelectItem>
<SelectItem value="allowlist">{pickAppText(locale, '白名单', 'Allowlist')}</SelectItem>
<SelectItem value="pair">{pickAppText(locale, '已配对', 'Paired')}</SelectItem>
<SelectItem value="disabled">{pickAppText(locale, '关闭', 'Disabled')}</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label={pickAppText(locale, '最大入站长度', 'Max inbound chars')}>
<Input
value={connectorForm.maxMessageChars}
onChange={(event) => setConnectorForm((prev) => ({ ...prev, maxMessageChars: event.target.value }))}
disabled={Boolean(connectorSession) || startingConnector}
placeholder="20000"
/>
</Field>
</div>
<Field label={pickAppText(locale, '允许私聊用户 Open ID', 'Allowed DM user Open IDs')}>
<Textarea
value={connectorForm.allowFrom}
onChange={(event) => setConnectorForm((prev) => ({ ...prev, allowFrom: event.target.value }))}
disabled={Boolean(connectorSession) || startingConnector}
placeholder="ou_xxx, ou_yyy"
/>
</Field>
<Field label={pickAppText(locale, '允许群 Chat ID', 'Allowed group Chat IDs')}>
<Textarea
value={connectorForm.groupAllowFrom}
onChange={(event) => setConnectorForm((prev) => ({ ...prev, groupAllowFrom: event.target.value }))}
disabled={Boolean(connectorSession) || startingConnector}
placeholder="oc_xxx, oc_yyy"
/>
</Field>
</div>
{connectorForm.mode === 'link' ? (
<div className="grid gap-4 md:grid-cols-2">
<Field label="App ID">
@ -997,8 +1124,8 @@ export default function StatusPage() {
<div className="space-y-4 rounded-lg border p-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{connectorSession.session.sessionId}</p>
<p className="text-xs text-muted-foreground">
<p className="break-all text-sm font-medium">{connectorSession.session.sessionId}</p>
<p className="break-all text-xs text-muted-foreground">
{connectorSession.connection?.channel_id || connectorSession.connection?.connection_id || '-'}
</p>
</div>
@ -1020,7 +1147,7 @@ export default function StatusPage() {
<div className="space-y-2 text-sm">
{connectorSession.session.instructions.map((item, index) => (
<div key={`${index}-${item}`} className="rounded-md border bg-muted/30 px-3 py-2">
{item}
<span className="break-words">{item}</span>
</div>
))}
</div>
@ -1032,12 +1159,12 @@ export default function StatusPage() {
</div>
) : null}
{connectorSession.session.error ? (
<p className="text-sm text-destructive">{connectorSession.session.error}</p>
<p className="break-words text-sm text-destructive">{connectorSession.session.error}</p>
) : null}
</div>
) : null}
{connectorError ? <p className="text-sm text-destructive">{connectorError}</p> : null}
{connectorError ? <p className="break-words text-sm text-destructive">{connectorError}</p> : null}
</div>
<DialogFooter>
@ -1073,31 +1200,60 @@ export default function StatusPage() {
</CardHeader>
<CardContent>
<div className="space-y-5">
<div className="grid gap-2 sm:grid-cols-3">
{(connectors.length ? connectors : [{ kind: 'telegram' }, { kind: 'weixin' }, { kind: 'feishu' }]).map((connector) => {
const supportsSession = SESSION_CONNECTOR_KINDS.has(connector.kind);
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{connectorCards.map((connector) => {
const channel = connectorChannelForKind(connector.kind, status.channels);
const connection = connectorConnectionForKind(connector.kind, channelConnections);
const isRunning = channel?.state === 'running' || connection?.status === 'connected';
const isLocalConnector = LOCAL_CONNECTOR_KINDS.has(connector.kind);
const canStart = SESSION_CONNECTOR_KINDS.has(connector.kind) && !channel && !isRunning;
return (
<button
key={connector.kind}
type="button"
onClick={() => supportsSession && openConnectorDialog(connector)}
disabled={!supportsSession}
onClick={() => {
if (isLocalConnector) {
openLocalConnectorDetails(connector.kind, channel);
} else if (channel) {
void openChannelDetails(channel);
} else if (canStart) {
openConnectorDialog(connector);
}
}}
disabled={!channel && !canStart && !isLocalConnector}
className={[
'flex min-h-[86px] w-full items-start justify-between rounded-lg border px-3 py-3 text-left text-sm transition',
supportsSession ? 'hover:border-primary/50 hover:bg-muted/40' : 'opacity-70',
'group flex min-h-[76px] w-full items-start justify-between gap-3 rounded-lg border p-3 text-left transition',
isRunning
? 'border-primary bg-primary/5 shadow-sm'
: canStart
? 'border-border bg-background hover:border-primary/50 hover:bg-muted/40'
: 'border-border bg-background opacity-70',
].join(' ')}
>
<span className="min-w-0 space-y-1">
<span className="flex items-center gap-2 font-medium">
{supportsSession ? <QrCode className="h-4 w-4" /> : <PlugZap className="h-4 w-4" />}
<span className="truncate">{connectorDisplayName(connector)}</span>
<span className="flex items-center gap-2 text-sm font-medium">
{isRunning ? (
<CheckCircle2 className="h-4 w-4 shrink-0 text-green-500" />
) : canStart || isLocalConnector ? (
<QrCode className="h-4 w-4 shrink-0" />
) : (
<PlugZap className="h-4 w-4 shrink-0 text-muted-foreground/50" />
)}
<span className={isRunning ? 'break-all' : 'break-all text-muted-foreground'}>
{connectorDisplayName(connector)}
</span>
</span>
<span className="block truncate text-xs text-muted-foreground">
{connectorAuthLabel(connector, locale)}
<span className="block break-words text-xs text-muted-foreground">
{connectorCardSubtitle(connector, channel, connection, locale)}
</span>
{channel || connection || isLocalConnector ? (
<span className="block break-all text-xs text-muted-foreground">
{connectorCardChannelLabel(channel, connection, locale)}
</span>
) : null}
</span>
<Badge variant={supportsSession ? 'outline' : 'secondary'}>
{supportsSession ? pickAppText(locale, '连接', 'Connect') : pickAppText(locale, '令牌', 'Token')}
<Badge variant={connectorCardBadgeVariant(channel, connection)} className="shrink-0">
{connectorCardBadgeLabel(connector, channel, connection, locale)}
</Badge>
</button>
);
@ -1109,33 +1265,6 @@ export default function StatusPage() {
{pickAppText(locale, '正在加载连接器', 'Loading connectors')}
</div>
) : null}
<div className="space-y-2">
{status.channels.length === 0 ? (
<p className="text-sm text-muted-foreground">
{pickAppText(locale, '尚未配置通道', 'No channels configured')}
</p>
) : (
status.channels.map((ch) => (
<button
key={ch.channel_id}
type="button"
onClick={() => openChannelDetails(ch)}
className="flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left text-sm hover:bg-muted/40"
>
<span className="min-w-0">
<span className="block truncate font-medium">{ch.display_name || ch.channel_id}</span>
<span className="block truncate text-xs text-muted-foreground">
{ch.channel_id} · {ch.kind}/{ch.mode} · {ch.account_id}
{typeof ch.connected_peers === 'number' ? ` · ${ch.connected_peers} peer${ch.connected_peers === 1 ? '' : 's'}` : ''}
</span>
</span>
<Badge variant={channelStateBadgeVariant(ch.state)}>
{ch.state}
</Badge>
</button>
))
)}
</div>
</div>
</CardContent>
</Card>
@ -1154,10 +1283,10 @@ function InfoRow({
ok?: boolean;
}) {
return (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<div className="flex items-center gap-2">
<code className="bg-muted px-2 py-0.5 rounded text-xs max-w-[400px] truncate">
<div className="grid min-w-0 gap-1 text-sm sm:grid-cols-[minmax(0,1fr)_minmax(0,auto)] sm:items-start">
<span className="min-w-0 break-words text-muted-foreground">{label}</span>
<div className="flex min-w-0 items-center gap-2 sm:justify-end">
<code className="min-w-0 max-w-full whitespace-normal break-all rounded bg-muted px-2 py-0.5 text-xs sm:max-w-[400px]">
{value}
</code>
{ok !== undefined &&
@ -1171,14 +1300,180 @@ function InfoRow({
);
}
function TerminalConnectionGuide({ channel, locale }: { channel: ChannelStatus; locale: AppLocale }) {
const connected = channel.state === 'running';
const instructions = terminalConnectionGuide(locale);
return (
<div className="space-y-4 rounded-lg border p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{pickAppText(locale, '小终端连接方式', 'Terminal connection method')}
</p>
<p className="text-xs text-muted-foreground">
{pickAppText(
locale,
'小终端通过本地 WebSocket 通道接入当前实例;这里展示的是连接状态和接入说明。',
'The terminal connects to this instance through the local WebSocket channel; this panel shows status and connection guidance.'
)}
</p>
</div>
<Badge variant={connected ? 'default' : 'secondary'}>{channel.state}</Badge>
</div>
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
<div className="rounded-md border bg-muted/30 p-3">
<div className="mx-auto grid h-44 w-44 grid-cols-7 grid-rows-7 gap-1 rounded bg-background p-3">
{TERMINAL_FAKE_QR_CELLS.map((filled, index) => (
<span
key={index}
className={filled ? 'rounded-[2px] bg-foreground' : 'rounded-[2px] bg-transparent'}
/>
))}
</div>
<p className="mt-3 text-center text-xs font-medium">
{pickAppText(locale, '示意二维码Fake QR', 'Illustrative QR (Fake QR)')}
</p>
<p className="mt-1 text-center text-xs text-muted-foreground">
{pickAppText(locale, '不可扫码,仅用于标识终端连接入口。', 'Not scannable; it only marks the terminal connection entry.')}
</p>
</div>
<div className="space-y-2 text-sm">
{instructions.map((item) => (
<div key={item} className="rounded-md border bg-muted/20 px-3 py-2">
{item}
</div>
))}
</div>
</div>
</div>
);
}
const TERMINAL_FAKE_QR_CELLS = [
true, true, true, false, true, true, true,
true, false, true, false, true, false, true,
true, true, true, false, true, true, true,
false, false, false, true, false, true, false,
true, false, true, false, true, false, true,
false, true, false, true, false, true, false,
true, false, true, true, false, false, true,
];
function terminalConnectionGuide(locale: AppLocale): string[] {
return [
pickAppText(locale, '保持本实例页面在线,终端客户端会通过 WebSocket 连接 Beaver。', 'Keep this instance online; the terminal client connects to Beaver over WebSocket.'),
pickAppText(locale, '连接成功后,通道状态会显示 running并显示当前 connected peers 数量。', 'After connection succeeds, the channel status shows running and the connected peers count is updated.'),
pickAppText(locale, '这里的二维码是 fake 的说明图,不代表真实扫码绑定流程。', 'The QR shown here is fake guidance artwork, not a real scan-to-bind flow.'),
];
}
function terminalFallbackChannel(locale: AppLocale): ChannelStatus {
return {
channel_id: 'terminal',
kind: 'terminal',
mode: 'websocket',
display_name: pickAppText(locale, '小终端', 'Terminal'),
enabled: false,
state: 'disabled',
account_id: 'local',
capabilities: ['receive_text', 'send_text'],
connected_peers: 0,
};
}
function providerLabel(provider: ProviderStatus): string {
return provider.label || provider.name;
}
function providerIdentity(provider: ProviderStatus): string {
const identity = (provider.id || provider.name || provider.label || '').trim().toLowerCase();
if (identity === 'vllm/local') return 'vllm';
return identity;
}
function visibleProvider(provider: ProviderStatus): boolean {
return VISIBLE_PROVIDER_IDS.has(providerIdentity(provider));
}
function connectorConnectionForKind(
kind: string,
connections: ChannelConnectionView[]
): ChannelConnectionView | undefined {
const matches = connections.filter((connection) => connection.kind === kind && connection.status !== 'revoked');
return (
matches.find((connection) => connection.status === 'connected') ||
matches.find((connection) => connection.status !== 'error') ||
matches[0]
);
}
function connectorCardSubtitle(
connector: ChannelConnectorDescriptor,
channel: ChannelStatus | undefined,
connection: ChannelConnectionView | undefined,
locale: AppLocale
): string {
if (channel?.state === 'running' || connection?.status === 'connected') {
return pickAppText(locale, '已连接', 'Connected');
}
if (channel) return channel.state;
if (connection) return connection.status;
if (connector.kind === 'terminal') return pickAppText(locale, '本地终端连接', 'Local terminal connection');
return connectorAuthLabel(connector, locale);
}
function connectorCardChannelLabel(
channel: ChannelStatus | undefined,
connection: ChannelConnectionView | undefined,
locale: AppLocale
): string {
const rawChannelId = connection?.channel_id || channel?.channel_id || '';
const channelId = compactMainSuffix(rawChannelId);
const accountId = compactMainSuffix(connection?.account_id || channel?.account_id || '');
const mode = connection?.mode || channel?.mode || '';
const parts = [channelId, mode, accountId].filter(Boolean);
if (!channel && !connection) return pickAppText(locale, '连接方式说明', 'Connection instructions');
if (parts.length === 0) return pickAppText(locale, '通道已连接', 'Channel connected');
return `${pickAppText(locale, '通道', 'Channel')}: ${parts.join(' · ')}`;
}
function compactMainSuffix(value: string): string {
return value.replace(/[-_]?main$/i, '').trim();
}
function connectorCardBadgeVariant(
channel: ChannelStatus | undefined,
connection: ChannelConnectionView | undefined
): 'default' | 'secondary' | 'destructive' | 'outline' {
if (channel?.state === 'running' || connection?.status === 'connected') return 'default';
if (channel?.state === 'error' || channel?.state === 'degraded' || connection?.status === 'error') {
return 'destructive';
}
if (channel?.state === 'disabled' || channel?.state === 'stopped' || connection?.status === 'revoked') {
return 'secondary';
}
return 'outline';
}
function connectorCardBadgeLabel(
connector: ChannelConnectorDescriptor,
channel: ChannelStatus | undefined,
connection: ChannelConnectionView | undefined,
locale: AppLocale
): string {
if (channel?.state === 'running' || connection?.status === 'connected') return 'running';
if (channel) return channel.state;
if (connection) return connection.status;
if (connector.kind === 'terminal') return pickAppText(locale, '说明', 'Guide');
return pickAppText(locale, '连接', 'Connect');
}
function connectorDisplayName(connector: Pick<ChannelConnectorDescriptor, 'kind' | 'displayName' | 'display_name'>): string {
if (connector.displayName || connector.display_name) return connector.displayName || connector.display_name || connector.kind;
if (connector.kind === 'weixin') return 'Weixin';
if (connector.kind === 'feishu') return 'Feishu/Lark';
if (connector.kind === 'terminal') return 'Terminal';
if (connector.kind === 'telegram') return 'Telegram';
return connector.kind;
}
@ -1187,6 +1482,7 @@ function connectorAuthLabel(connector: ChannelConnectorDescriptor, locale: AppLo
const authType = connector.authType || connector.auth_type;
if (connector.kind === 'weixin') return authType || pickAppText(locale, 'QR', 'QR');
if (connector.kind === 'feishu') return authType || pickAppText(locale, '插件', 'Plugin');
if (connector.kind === 'terminal') return pickAppText(locale, 'Fake QR', 'Fake QR');
if (connector.kind === 'telegram') return authType || pickAppText(locale, 'Token', 'Token');
return authType || connector.kind;
}
@ -1217,19 +1513,10 @@ function connectorSessionBadgeVariant(status: string): 'default' | 'secondary' |
return 'outline';
}
function channelStateBadgeVariant(
state: ChannelStatus['state']
): 'default' | 'secondary' | 'destructive' | 'outline' {
if (state === 'running') return 'default';
if (state === 'error' || state === 'degraded') return 'destructive';
if (state === 'disabled' || state === 'stopped') return 'secondary';
return 'outline';
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
function Field({ id, label, children }: { id?: string; label: string; children: React.ReactNode }) {
return (
<div className="grid gap-2">
<Label>{label}</Label>
<div className="grid min-w-0 gap-2">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);

View File

@ -19,7 +19,7 @@ import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
import { shouldPollTaskDetail, taskDetailDurationMs } from '@/lib/task-detail-refresh';
import { buildTaskTimelineCards } from '@/lib/task-timeline';
import { buildTaskTimelineView } from '@/lib/task-timeline-view';
import type { BackendTask } from '@/types';
const TERMINAL_TASK_STATUSES = new Set(['closed', 'abandoned', 'cancelled', 'error']);
@ -45,6 +45,7 @@ export default function TaskDetailPage() {
const mountedRef = React.useRef(true);
React.useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
@ -89,44 +90,17 @@ export default function TaskDetailPage() {
return () => window.clearInterval(id);
}, [backendTask, loadBackendTask]);
const taskRunIds = useMemo(() => {
const ids = new Set<string>();
for (const run of backendTask?.process_runs ?? []) ids.add(run.run_id);
for (const runId of backendTask?.run_ids ?? []) ids.add(runId);
return ids;
}, [backendTask]);
const liveRuns = useMemo(
() => processRuns.filter((run) => taskRunIds.has(run.run_id) || run.metadata?.task_id === taskId),
[processRuns, taskId, taskRunIds]
);
const liveEvents = useMemo(
() => processEvents.filter((event) => taskRunIds.has(event.run_id) || event.metadata?.task_id === taskId),
[processEvents, taskId, taskRunIds]
);
const liveArtifacts = useMemo(
() => processArtifacts.filter((artifact) => taskRunIds.has(artifact.run_id) || artifact.metadata?.task_id === taskId),
[processArtifacts, taskId, taskRunIds]
);
const renderedRuns = liveRuns.length > 0 ? liveRuns : backendTask?.process_runs ?? [];
const renderedEvents = liveEvents.length > 0 ? liveEvents : backendTask?.process_events ?? [];
const renderedArtifacts = liveArtifacts.length > 0 ? liveArtifacts : backendTask?.process_artifacts ?? [];
const timelineCards = useMemo(
const timelineView = useMemo(
() =>
backendTask
? buildTaskTimelineCards({
task: backendTask,
processRuns: renderedRuns,
processEvents: renderedEvents,
processArtifacts: renderedArtifacts,
})
: [],
[backendTask, renderedArtifacts, renderedEvents, renderedRuns]
buildTaskTimelineView({
task: backendTask,
liveRuns: processRuns,
liveEvents: processEvents,
liveArtifacts: processArtifacts,
}),
[backendTask, processArtifacts, processEvents, processRuns]
);
const timelineCards = timelineView?.cards ?? [];
const activeLabel =
[...timelineCards].reverse().find((card) => !['acceptance', 'task_created'].includes(card.type))?.title ?? '-';
@ -164,13 +138,13 @@ export default function TaskDetailPage() {
<div className="min-h-screen bg-background">
<TaskLiveHeader task={backendTask} activeLabel={activeLabel} durationMs={durationMs} reviewTargetId={TASK_RESULT_REVIEW_ID} />
<main className="mx-auto grid max-w-7xl gap-6 p-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-4">
<main className="mx-auto grid min-w-0 max-w-7xl gap-6 p-4 sm:p-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="min-w-0 space-y-4">
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
className="h-11 text-destructive hover:text-destructive"
disabled={Boolean(actionBusy)}
onClick={() => void deleteCurrentBackendTask()}
>
@ -217,7 +191,12 @@ export default function TaskDetailPage() {
/>
</div>
<TaskSideRail task={backendTask} runs={renderedRuns} artifacts={renderedArtifacts} cards={timelineCards} />
<TaskSideRail
task={backendTask}
runs={timelineView?.process.runs ?? []}
artifacts={timelineView?.process.artifacts ?? []}
cards={timelineCards}
/>
</main>
</div>
);
@ -225,7 +204,7 @@ export default function TaskDetailPage() {
return (
<div className="mx-auto flex max-w-4xl flex-col gap-4 p-6">
<Button asChild variant="outline" className="w-fit">
<Button asChild variant="outline" className="h-11 w-fit">
<Link href="/tasks">
<ArrowLeft className="mr-2 h-4 w-4" />
{pickAppText(locale, '返回任务列表', 'Back to tasks')}

View File

@ -46,6 +46,7 @@ export default function TasksPage() {
function OrdinaryTasks() {
const { locale } = useAppI18n();
const [backendTasks, setBackendTasks] = useState<BackendTask[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const visibleTasks = useMemo(
@ -58,17 +59,25 @@ function OrdinaryTasks() {
const loadBackendTasks = React.useCallback(() => {
let cancelled = false;
setLoading(true);
setError(null);
listBackendTasks()
.then((items) => {
if (!cancelled) setBackendTasks(Array.isArray(items) ? items : []);
})
.catch(() => {
if (!cancelled) setBackendTasks([]);
.catch((err: any) => {
if (!cancelled) {
setBackendTasks([]);
setError(err.message || pickAppText(locale, '加载任务失败', 'Failed to load tasks'));
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
}, [locale]);
useEffect(() => loadBackendTasks(), [loadBackendTasks]);
@ -86,7 +95,18 @@ function OrdinaryTasks() {
}
};
if (visibleTasks.length === 0) {
if (loading) {
return (
<Card>
<CardContent className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{pickAppText(locale, '加载任务中', 'Loading tasks')}
</CardContent>
</Card>
);
}
if (visibleTasks.length === 0 && !error) {
return (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
@ -113,7 +133,19 @@ function OrdinaryTasks() {
</CardContent>
</Card>
)}
<Card>
{visibleTasks.length > 0 ? (
<>
<div className="grid gap-3 xl:hidden">
{visibleTasks.map((task) => (
<OrdinaryTaskCard
key={task.task_id}
task={task}
locale={locale}
onDelete={() => void handleDeleteBackendTask(task)}
/>
))}
</div>
<Card className="hidden xl:block">
<CardContent className="p-0">
<Table>
<TableHeader>
@ -154,7 +186,7 @@ function OrdinaryTasks() {
<TableCell className="text-xs text-muted-foreground">{formatTaskRuntimeTime(task.updated_at, locale)}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button asChild size="sm" variant="outline">
<Button asChild size="sm" variant="outline" className="h-11">
<Link href={`/tasks/${encodeURIComponent(task.task_id)}`}>
{pickAppText(locale, '进入', 'Open')}
<ArrowRight className="ml-2 h-3.5 w-3.5" />
@ -163,8 +195,9 @@ function OrdinaryTasks() {
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-destructive hover:text-destructive"
className="h-11 w-11 text-destructive hover:text-destructive"
onClick={() => void handleDeleteBackendTask(task)}
aria-label={pickAppText(locale, `删除任务 ${task.short_title || task.task_id}`, `Delete task ${task.short_title || task.task_id}`)}
title={pickAppText(locale, '删除任务', 'Delete task')}
>
<Trash2 className="h-3.5 w-3.5" />
@ -177,10 +210,80 @@ function OrdinaryTasks() {
</Table>
</CardContent>
</Card>
</>
) : null}
</div>
);
}
function OrdinaryTaskCard({
task,
locale,
onDelete,
}: {
task: BackendTask;
locale: 'zh-CN' | 'en-US';
onDelete: () => void;
}) {
const title = task.short_title || String(task.metadata?.short_title || '') || task.description || task.goal || task.task_id;
return (
<Card className="rounded-md">
<CardContent className="space-y-4 p-4">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h2 className="min-w-0 flex-1 text-base font-semibold">{title}</h2>
{task.is_open ? <Badge variant="secondary">{pickAppText(locale, '进行中', 'Active')}</Badge> : null}
</div>
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{task.description || task.session_id}
</p>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-xs">
<div>
<div className="text-muted-foreground">{pickAppText(locale, '状态', 'Status')}</div>
<Badge className="mt-1" variant={task.status === 'awaiting_acceptance' || task.status === 'closed' ? 'default' : 'secondary'}>
{taskStatusLabel(task.status, locale)}
</Badge>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '来源', 'Source')}</div>
<div className="mt-1 text-sm">{taskSourceLabel(task, locale)}</div>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '运行 / 技能', 'Runs / skills')}</div>
<div className="mt-1 text-sm">{task.run_ids.length} / {task.skill_names.length}</div>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '更新时间', 'Updated')}</div>
<div className="mt-1 text-sm">{formatTaskRuntimeTime(task.updated_at, locale)}</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-border pt-3">
<Button
size="icon"
variant="ghost"
className="h-11 w-11 text-destructive hover:text-destructive"
onClick={onDelete}
aria-label={pickAppText(locale, `删除任务 ${title}`, `Delete task ${title}`)}
title={pickAppText(locale, '删除任务', 'Delete task')}
>
<Trash2 className="h-4 w-4" />
</Button>
<Button asChild variant="outline" className="h-11">
<Link href={`/tasks/${encodeURIComponent(task.task_id)}`}>
{pickAppText(locale, '进入任务', 'Open task')}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
);
}
function taskStatusLabel(status: string, locale: 'zh-CN' | 'en-US') {
const labels: Record<string, [string, string]> = {
open: ['已创建', 'Open'],
@ -246,6 +349,13 @@ function ScheduledTasks() {
}
};
const handleRemoveJob = (job: CronJob) => {
if (!window.confirm(pickAppText(locale, `删除定时任务“${job.name}”?`, `Delete scheduled task "${job.name}"?`))) {
return;
}
void runJobAction(() => removeCronJob(job.id));
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
@ -254,11 +364,11 @@ function ScheduledTasks() {
{pickAppText(locale, '每次触发会生成通知记录;需要修改时再接入 Task。', 'Each trigger creates a notification record; connect it to a Task when revision is needed.')}
</div>
<div className="flex items-center gap-2">
<Button onClick={() => void loadJobs()} variant="outline" size="sm">
<Button onClick={() => void loadJobs()} variant="outline" size="sm" className="h-11">
<RefreshCw className="mr-2 h-4 w-4" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button>
<Button onClick={() => setShowAdd(true)} size="sm">
<Button onClick={() => setShowAdd(true)} size="sm" className="h-11">
<Plus className="mr-2 h-4 w-4" />
{pickAppText(locale, '新建定时任务', 'New scheduled task')}
</Button>
@ -287,7 +397,23 @@ function ScheduledTasks() {
/>
)}
<Card>
{!loading && jobs.length > 0 ? (
<div className="grid gap-3 xl:hidden">
{jobs.map((job) => (
<ScheduledJobCard
key={job.id}
job={job}
locale={locale}
formatTime={formatTime}
onToggle={(checked) => void runJobAction(() => toggleCronJob(job.id, checked))}
onRun={() => void runJobAction(() => runCronJob(job.id))}
onRemove={() => handleRemoveJob(job)}
/>
))}
</div>
) : null}
<Card className={!loading && jobs.length > 0 ? 'hidden xl:block' : undefined}>
<CardContent className="p-0">
{loading ? (
<div className="flex items-center justify-center py-16 text-muted-foreground">
@ -316,7 +442,11 @@ function ScheduledTasks() {
{jobs.map((job) => (
<TableRow key={job.id}>
<TableCell>
<Switch checked={job.enabled} onCheckedChange={(checked) => void runJobAction(() => toggleCronJob(job.id, checked))} />
<Switch
checked={job.enabled}
onCheckedChange={(checked) => void runJobAction(() => toggleCronJob(job.id, checked))}
aria-label={pickAppText(locale, `切换定时任务 ${job.name}`, `Toggle scheduled task ${job.name}`)}
/>
</TableCell>
<TableCell>
<div className="font-medium">{job.name}</div>
@ -330,7 +460,7 @@ function ScheduledTasks() {
<span className="block max-w-[260px] truncate text-sm">{job.message}</span>
</TableCell>
<TableCell>
<Button asChild size="sm" variant="outline" disabled={!job.last_scheduled_run_id && !job.last_task_id}>
<Button asChild size="sm" variant="outline" className="h-11" disabled={!job.last_scheduled_run_id && !job.last_task_id}>
<Link href={job.last_scheduled_run_id ? `/notifications/${encodeURIComponent(job.last_scheduled_run_id)}` : job.last_task_id ? `/tasks/${encodeURIComponent(job.last_task_id)}` : '/tasks'}>
<FolderDown className="mr-2 h-3.5 w-3.5" />
{formatTime(job.last_run_at_ms)}
@ -348,10 +478,24 @@ function ScheduledTasks() {
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => void runJobAction(() => runCronJob(job.id))}>
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => void runJobAction(() => runCronJob(job.id))}
aria-label={pickAppText(locale, `立即运行 ${job.name}`, `Run ${job.name} now`)}
title={pickAppText(locale, '立即运行', 'Run now')}
>
<Play className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={() => void runJobAction(() => removeCronJob(job.id))}>
<Button
variant="ghost"
size="icon"
className="h-11 w-11 text-destructive hover:text-destructive"
onClick={() => handleRemoveJob(job)}
aria-label={pickAppText(locale, `删除定时任务 ${job.name}`, `Delete scheduled task ${job.name}`)}
title={pickAppText(locale, '删除定时任务', 'Delete scheduled task')}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
@ -367,6 +511,112 @@ function ScheduledTasks() {
);
}
function ScheduledJobCard({
job,
locale,
formatTime,
onToggle,
onRun,
onRemove,
}: {
job: CronJob;
locale: 'zh-CN' | 'en-US';
formatTime: (ms: number | null) => string;
onToggle: (checked: boolean) => void;
onRun: () => void;
onRemove: () => void;
}) {
const historyHref = job.last_scheduled_run_id
? `/notifications/${encodeURIComponent(job.last_scheduled_run_id)}`
: job.last_task_id
? `/tasks/${encodeURIComponent(job.last_task_id)}`
: '/tasks';
return (
<Card className="rounded-md">
<CardContent className="space-y-4 p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h2 className="text-base font-semibold">{job.name}</h2>
<p className="mt-1 break-all text-xs text-muted-foreground">{job.id}</p>
</div>
<label className="flex min-h-11 shrink-0 items-center gap-2 text-sm">
<span className="sr-only">{pickAppText(locale, '启用', 'Enabled')}</span>
<Switch
checked={job.enabled}
onCheckedChange={onToggle}
aria-label={pickAppText(locale, `切换定时任务 ${job.name}`, `Toggle scheduled task ${job.name}`)}
/>
</label>
</div>
<p className="text-sm leading-6 text-muted-foreground">{job.message}</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-xs">
<div>
<div className="text-muted-foreground">{pickAppText(locale, '计划', 'Schedule')}</div>
<code className="mt-1 inline-block rounded bg-muted px-1.5 py-0.5">{job.schedule_display}</code>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '下次运行', 'Next run')}</div>
<div className="mt-1 text-sm">{formatTime(job.next_run_at_ms)}</div>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '上次运行', 'Last run')}</div>
<div className="mt-1 text-sm">{formatTime(job.last_run_at_ms)}</div>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '状态', 'Status')}</div>
<div className="mt-1">
{job.last_status === 'ok' ? (
<Badge>{pickAppText(locale, '成功', 'OK')}</Badge>
) : job.last_status === 'error' ? (
<Badge variant="destructive">{pickAppText(locale, '错误', 'Error')}</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-3">
<Button
asChild
variant="outline"
className="h-11"
disabled={!job.last_scheduled_run_id && !job.last_task_id}
>
<Link href={historyHref}>
<FolderDown className="mr-2 h-4 w-4" />
{pickAppText(locale, '运行历史', 'History')}
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={onRun}
aria-label={pickAppText(locale, `立即运行 ${job.name}`, `Run ${job.name} now`)}
title={pickAppText(locale, '立即运行', 'Run now')}
>
<Play className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-11 w-11 text-destructive hover:text-destructive"
onClick={onRemove}
aria-label={pickAppText(locale, `删除定时任务 ${job.name}`, `Delete scheduled task ${job.name}`)}
title={pickAppText(locale, '删除定时任务', 'Delete scheduled task')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
);
}
function AddJobForm({
targetSessionKey,
onAdd,
@ -403,7 +653,14 @@ function AddJobForm({
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{pickAppText(locale, '新建定时任务', 'New scheduled task')}</CardTitle>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}>
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={onCancel}
aria-label={pickAppText(locale, '关闭新建定时任务表单', 'Close new scheduled task form')}
title={pickAppText(locale, '关闭', 'Close')}
>
<X className="h-4 w-4" />
</Button>
</div>
@ -452,8 +709,8 @@ function AddJobForm({
</p>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>{pickAppText(locale, '取消', 'Cancel')}</Button>
<Button type="submit" disabled={!name.trim() || !message.trim()}>
<Button type="button" variant="outline" className="h-11" onClick={onCancel}>{pickAppText(locale, '取消', 'Cancel')}</Button>
<Button type="submit" className="h-11" disabled={!name.trim() || !message.trim()}>
<Plus className="mr-2 h-4 w-4" />
{pickAppText(locale, '创建', 'Create')}
</Button>

View File

@ -4,6 +4,7 @@ import React from 'react';
import { usePathname } from 'next/navigation';
import { getStatus, listSessions, wsManager } from '@/lib/api';
import { runtimeBridgeEnabledForPath } from '@/lib/channel-connector-state';
import { useChatStore } from '@/lib/store';
import type { ProcessWsEvent, SessionUpdatedEvent, WsEvent } from '@/types';
@ -47,7 +48,7 @@ export function AppRuntimeBridge() {
const ingestProcessEvent = useChatStore((state) => state.ingestProcessEvent);
const statusCheckCleanupRef = React.useRef<(() => void) | null>(null);
const statusCheckInFlightRef = React.useRef(false);
const chatRuntimeEnabled = pathname === '/' || pathname.startsWith('/tasks') || pathname.startsWith('/notifications');
const chatRuntimeEnabled = runtimeBridgeEnabledForPath(pathname);
const loadSessions = React.useCallback(async () => {
try {

View File

@ -3,7 +3,7 @@
import React from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { Bell, Bot, ChevronDown, FolderOpen, ListTodo, LogOut, Mail, MessageSquare, Puzzle, Settings, Store, Wrench } from 'lucide-react';
import { Bell, Bot, ChevronDown, FolderOpen, ListTodo, LogOut, Mail, Menu, MessageSquare, Puzzle, Settings, Store, Wrench, X } from 'lucide-react';
import { logout } from '@/lib/api';
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
@ -69,6 +69,7 @@ const Header = () => {
const { locale } = useAppI18n();
const pathname = usePathname();
const router = useRouter();
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
const user = useChatStore((s) => s.user);
const isAuthLoading = useChatStore((s) => s.isAuthLoading);
const setUser = useChatStore((s) => s.setUser);
@ -93,108 +94,175 @@ const Header = () => {
router.refresh();
};
React.useEffect(() => {
setMobileMenuOpen(false);
}, [pathname]);
React.useEffect(() => {
if (!mobileMenuOpen) return;
const previousOverflow = document.body.style.overflow;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setMobileMenuOpen(false);
}
};
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
};
}, [mobileMenuOpen]);
const userInitial = (user?.username || user?.email || '?').trim().charAt(0).toUpperCase();
return (
<header className="fixed left-0 right-0 top-0 z-50 border-b border-[#E6E1DE] bg-[#F7F6F5]/95 backdrop-blur">
<div className="mx-auto max-w-[1720px] px-4 sm:px-6 lg:px-8">
<div className="grid h-16 grid-cols-[minmax(120px,1fr)_auto_minmax(120px,1fr)] items-center gap-4">
<Link href="/" className="flex shrink-0 items-center">
<span className="font-serif text-[28px] font-semibold leading-none text-[#0B0B0B]">
Beaver
</span>
</Link>
const renderNavLinks = (compact = false) =>
NAV_ITEMS.map((item) => {
const isActive =
item.href === '/'
? pathname === '/'
: item.matchPrefixes?.some((prefix) => pathname.startsWith(prefix)) ?? pathname.startsWith(item.href);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
onClick={compact ? () => setMobileMenuOpen(false) : undefined}
className={`flex h-11 shrink-0 items-center gap-2 rounded-full text-sm font-medium transition-colors ${
compact ? 'justify-start rounded-lg border border-transparent bg-background px-4' : 'px-4'
} ${
isActive
? 'bg-primary text-primary-foreground'
: compact
? 'text-[#4F4642] hover:border-[#E6E1DE] hover:bg-muted hover:text-[#0B0B0B]'
: 'text-[#4F4642] hover:bg-[#F7F5F4] hover:text-[#0B0B0B]'
}`}
>
<Icon className="h-4 w-4" />
{navLabel(item.key)}
</Link>
);
});
<nav className="flex items-center gap-1 rounded-full border border-[#E6E1DE] bg-white px-1.5 py-1 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
{NAV_ITEMS.map((item) => {
const isActive =
item.href === '/'
? pathname === '/'
: item.matchPrefixes?.some((prefix) => pathname.startsWith(prefix)) ?? pathname.startsWith(item.href);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className={`flex shrink-0 items-center gap-1.5 rounded-full px-4 py-2 text-sm font-medium transition-colors ${
isActive
? 'bg-primary text-primary-foreground'
: 'text-[#4F4642] hover:bg-[#F7F5F4] hover:text-[#0B0B0B]'
}`}
>
<Icon className="w-4 h-4" />
{navLabel(item.key)}
</Link>
);
})}
return (
<>
<header className="fixed left-0 right-0 top-0 z-50 border-b border-[#E6E1DE] bg-[#F7F6F5]/95 backdrop-blur">
<div className="mx-auto max-w-[1720px] px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-[#E6E1DE] bg-white text-[#1D1715] transition-colors hover:bg-[#F7F5F4] 2xl:hidden"
aria-label={mobileMenuOpen ? pickAppText(locale, '关闭导航', 'Close navigation') : pickAppText(locale, '打开导航', 'Open navigation')}
aria-expanded={mobileMenuOpen}
aria-controls="app-primary-mobile-nav"
onClick={() => setMobileMenuOpen((open) => !open)}
>
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
<Link href="/" className="hidden h-11 shrink-0 items-center min-[360px]:flex">
<span className="font-serif text-[26px] font-semibold leading-none text-[#0B0B0B] sm:text-[28px]">
Beaver
</span>
</Link>
</div>
<nav className="hidden items-center gap-1 rounded-full border border-[#E6E1DE] bg-white px-1.5 py-1 shadow-[0_1px_2px_rgba(0,0,0,0.04)] 2xl:flex">
{renderNavLinks(false)}
</nav>
<div className="flex min-w-0 items-center justify-end gap-3">
<div className="hidden shrink-0 sm:block">
<ConnectionDot />
</div>
<div className="flex shrink-0 items-center gap-2">
<LanguageSwitcher />
{user ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center gap-2 rounded-full border border-[#E6E1DE] bg-white px-2 py-1.5 text-sm font-medium text-[#1D1715] transition-colors hover:bg-[#F7F5F4]"
>
<Avatar className="h-8 w-8 border border-[#E6E1DE]">
<AvatarFallback className="bg-primary text-xs font-semibold text-primary-foreground">
{userInitial}
</AvatarFallback>
</Avatar>
<span className="hidden max-w-28 truncate sm:block">{user.username}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 rounded-3xl border-border/70 p-0 shadow-2xl">
<div className="overflow-hidden rounded-3xl bg-[linear-gradient(180deg,#F7F5F4,#FFFFFF)]">
<div className="border-b border-border/60 px-6 py-5">
<p className="truncate text-center text-sm font-medium text-muted-foreground">
{user.email}
</p>
</div>
<div className="flex flex-col items-center gap-4 px-6 py-6 text-center">
<Avatar className="h-24 w-24 border-4 border-white shadow-sm">
<AvatarFallback className="bg-primary text-4xl font-semibold text-primary-foreground">
<div className="flex min-w-0 shrink-0 items-center justify-end gap-2 sm:gap-3">
<div className="hidden shrink-0 xl:block">
<ConnectionDot />
</div>
<div className="flex shrink-0 items-center gap-2">
<LanguageSwitcher />
{user ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="flex h-11 w-11 items-center justify-center gap-2 rounded-full border border-[#E6E1DE] bg-white px-1.5 text-sm font-medium text-[#1D1715] transition-colors hover:bg-[#F7F5F4] sm:w-auto sm:justify-start sm:px-2"
aria-label={pickAppText(locale, '打开账号菜单', 'Open account menu')}
>
<Avatar className="h-8 w-8 border border-[#E6E1DE]">
<AvatarFallback className="bg-primary text-xs font-semibold text-primary-foreground">
{userInitial}
</AvatarFallback>
</Avatar>
<div className="space-y-1">
<p className="text-2xl font-semibold tracking-tight text-foreground">
{pickAppText(locale, `${user.username},你好!`, `Hi, ${user.username}`)}
</p>
<p className="text-sm text-muted-foreground">
{pickAppText(locale, '当前已登录到你的工作区实例。', 'You are currently signed in to your workspace instance.')}
<span className="hidden max-w-28 truncate sm:block">{user.username}</span>
<ChevronDown className="hidden h-4 w-4 text-muted-foreground sm:block" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 rounded-3xl border-border/70 p-0 shadow-2xl">
<div className="overflow-hidden rounded-3xl bg-[linear-gradient(180deg,#F7F5F4,#FFFFFF)]">
<div className="border-b border-border/60 px-6 py-5">
<p className="truncate text-center text-sm font-medium text-muted-foreground">
{user.email}
</p>
</div>
</div>
<div className="border-t border-border/60 bg-white/90 px-4 py-4">
<Button
type="button"
variant="outline"
onClick={handleLogout}
className="h-12 w-full justify-center rounded-2xl text-sm font-semibold"
>
<LogOut className="mr-2 h-4 w-4" />
{pickAppText(locale, '退出登录', 'Sign Out')}
</Button>
<div className="flex flex-col items-center gap-4 px-6 py-6 text-center">
<Avatar className="h-24 w-24 border-4 border-white shadow-sm">
<AvatarFallback className="bg-primary text-4xl font-semibold text-primary-foreground">
{userInitial}
</AvatarFallback>
</Avatar>
<div className="space-y-1">
<p className="text-2xl font-semibold tracking-tight text-foreground">
{pickAppText(locale, `${user.username},你好!`, `Hi, ${user.username}`)}
</p>
<p className="text-sm text-muted-foreground">
{pickAppText(locale, '当前已登录到你的工作区实例。', 'You are currently signed in to your workspace instance.')}
</p>
</div>
</div>
<div className="border-t border-border/60 bg-white/90 px-4 py-4">
<Button
type="button"
variant="outline"
onClick={handleLogout}
className="h-12 w-full justify-center rounded-2xl text-sm font-semibold"
>
<LogOut className="mr-2 h-4 w-4" />
{pickAppText(locale, '退出登录', 'Sign Out')}
</Button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
) : !isAuthLoading ? null : null}
</PopoverContent>
</Popover>
) : !isAuthLoading ? null : null}
</div>
</div>
</div>
</div>
</div>
</header>
</header>
{mobileMenuOpen && (
<>
<button
type="button"
className="fixed inset-x-0 bottom-0 top-16 z-40 bg-black/40 2xl:hidden"
aria-label={pickAppText(locale, '关闭导航', 'Close navigation')}
onClick={() => setMobileMenuOpen(false)}
/>
<nav
id="app-primary-mobile-nav"
aria-label={pickAppText(locale, '主导航', 'Primary navigation')}
className="fixed bottom-0 left-0 top-16 z-[45] isolate w-[min(86vw,320px)] overflow-y-auto border-r border-[#E6E1DE] bg-background text-foreground shadow-[12px_0_32px_rgba(29,23,21,0.24)] animate-in slide-in-from-left-full duration-200 2xl:hidden"
>
<div className="min-h-full bg-background px-4 py-5">
<div className="grid gap-2 bg-background">
{renderNavLinks(true)}
</div>
</div>
</nav>
</>
)}
</>
);
};

View File

@ -27,7 +27,7 @@ export function LanguageSwitcher({ className }: { className?: string }) {
type="button"
onClick={() => setLocale(option.value)}
className={cn(
'rounded px-2 py-1 text-xs font-medium transition-colors',
'h-11 w-11 rounded text-xs font-medium transition-colors',
locale === option.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'

View File

@ -1,254 +1,21 @@
'use client';
import React from 'react';
import {
AlertCircle,
CheckCircle2,
Circle,
FileJson,
FileOutput,
FileText,
Image as ImageIcon,
Link2,
ListChecks,
Loader2,
PanelRightOpen,
X,
} from 'lucide-react';
import { Activity, PanelRightOpen, X } from 'lucide-react';
import { TaskTimeline } from '@/components/task-detail';
import { ScrollArea } from '@/components/ui/scroll-area';
import { appStatusLabel } from '@/lib/i18n/common';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import type {
SessionProgressArtifactView,
SessionProgressStepView,
SessionProgressView,
} from '@/lib/session-progress';
import type { ProcessArtifact, ProcessRunStatus } from '@/types';
function formatShortTime(value: string, locale: 'zh-CN' | 'en-US') {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(locale, {
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
function statusTone(status: ProcessRunStatus) {
if (status === 'done') return 'text-[#2F8D50] bg-[#E3F1E7] border-[#B8D9C2]';
if (status === 'running') return 'text-[#2F6FCA] bg-[#E7EEF9] border-[#B8CBE8]';
if (status === 'error') return 'text-[#8A3A2D] bg-[#F0E5E1] border-[#D9BDB4]';
if (status === 'cancelled') return 'text-[#6A5E58] bg-[#ECE8E5] border-[#D8D2CE]';
return 'text-[#6A5E58] bg-[#F0ECE9] border-[#D8D2CE]';
}
function StepMarker({ step, index }: { step: SessionProgressStepView; index: number }) {
if (step.status === 'done') {
return (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#2F8D50] text-white">
<CheckCircle2 className="h-4 w-4" />
</span>
);
}
if (step.status === 'running') {
return (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#2F6FCA] text-[11px] font-semibold text-white">
{index + 1}
</span>
);
}
if (step.status === 'error') {
return (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#8A3A2D] text-white">
<AlertCircle className="h-4 w-4" />
</span>
);
}
return (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#D8D2CE] text-[#6A5E58]">
<Circle className="h-3.5 w-3.5" />
</span>
);
}
function artifactIcon(type: ProcessArtifact['artifact_type']) {
if (type === 'json') return <FileJson className="h-4 w-4" />;
if (type === 'image') return <ImageIcon className="h-4 w-4" />;
if (type === 'link') return <Link2 className="h-4 w-4" />;
if (type === 'markdown' || type === 'text') return <FileText className="h-4 w-4" />;
return <FileOutput className="h-4 w-4" />;
}
function ProgressHeader({ view }: { view: SessionProgressView }) {
const { locale } = useAppI18n();
const percent = view.progress.percent;
return (
<section className="rounded-lg border border-[#ECE7E3] bg-white px-4 py-4 shadow-[0_8px_24px_rgba(0,0,0,0.04)]">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#E3F1E7] text-[#2F8D50]">
<ListChecks className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="line-clamp-2 text-sm font-semibold text-foreground">{view.title}</div>
<div className="mt-2 flex items-center gap-2">
<span className={`rounded-full border px-2 py-0.5 text-[11px] font-medium ${statusTone(view.status)}`}>
{appStatusLabel(view.status, locale)}
</span>
<span className="text-[11px] text-muted-foreground">
{pickAppText(locale, '更新于', 'Updated')} {formatShortTime(view.updatedAt, locale)}
</span>
</div>
</div>
</div>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>{view.progress.label}</span>
{percent !== null && <span className="font-medium text-foreground">{percent}%</span>}
</div>
<div className="h-2 overflow-hidden rounded-full bg-[#ECE8E5]">
<div
className="h-full rounded-full bg-[#5DB56F] transition-all"
style={{ width: `${percent ?? 0}%` }}
/>
</div>
</div>
{view.summary && (
<p className="mt-3 line-clamp-3 text-xs leading-5 text-muted-foreground">{view.summary}</p>
)}
</section>
);
}
function StepList({ steps }: { steps: SessionProgressStepView[] }) {
const { locale } = useAppI18n();
return (
<section className="rounded-lg border border-[#ECE7E3] bg-white px-4 py-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">
{pickAppText(locale, '运行步骤', 'Run Steps')}
</h3>
<span className="text-xs text-muted-foreground">
{pickAppText(locale, `${steps.length}`, `${steps.length} steps`)}
</span>
</div>
<div className="space-y-0">
{steps.map((step, index) => (
<div key={step.runId} className="grid grid-cols-[24px_1fr] gap-3">
<div className="flex flex-col items-center">
<StepMarker step={step} index={index} />
{index < steps.length - 1 && <span className="mt-2 h-full min-h-8 w-px bg-[#E6E1DE]" />}
</div>
<div className="pb-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="line-clamp-2 text-sm font-medium text-foreground">
{index + 1}. {step.title}
</div>
<div className="mt-1 text-[11px] text-muted-foreground">
{step.actorName} · {formatShortTime(step.updatedAt, locale)}
</div>
</div>
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] ${statusTone(step.status)}`}>
{appStatusLabel(step.status, locale)}
</span>
</div>
{step.description && (
<p className="mt-2 line-clamp-3 text-xs leading-5 text-muted-foreground">
{step.description}
</p>
)}
{step.status === 'running' && (
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-[#2F6FCA]">
<Loader2 className="h-3 w-3 animate-spin" />
<span>{pickAppText(locale, '正在处理', 'In progress')}</span>
</div>
)}
</div>
</div>
))}
</div>
</section>
);
}
function ArtifactRow({ artifact }: { artifact: SessionProgressArtifactView }) {
return (
<a
href={artifact.url || undefined}
target={artifact.url ? '_blank' : undefined}
rel={artifact.url ? 'noreferrer' : undefined}
className="block rounded-lg border border-[#ECE7E3] bg-[#FDFDFC] px-3 py-3 transition-colors hover:bg-[#F7F6F5]"
>
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#ECE8E5] text-[#5F5550]">
{artifactIcon(artifact.type)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{artifact.title}</div>
<div className="mt-1 text-[11px] text-muted-foreground">
{artifact.actorName} · {artifact.typeLabel}
</div>
<p className="mt-2 line-clamp-2 text-xs leading-5 text-muted-foreground">{artifact.preview}</p>
</div>
</div>
</a>
);
}
function ArtifactSection({ view }: { view: SessionProgressView }) {
const { locale } = useAppI18n();
return (
<section className="rounded-lg border border-[#ECE7E3] bg-white px-4 py-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">
{pickAppText(locale, '生成内容', 'Generated Content')}
</h3>
<span className="text-xs text-muted-foreground">
{pickAppText(locale, `${view.artifacts.length}`, `${view.artifacts.length} items`)}
</span>
</div>
{view.artifactTypeSummaries.length > 0 ? (
<div className="mb-3 flex flex-wrap gap-2">
{view.artifactTypeSummaries.map((item) => (
<span
key={item.type}
className="inline-flex items-center gap-1.5 rounded-full border border-[#E6E1DE] bg-[#F7F6F5] px-2.5 py-1 text-xs text-[#4F4642]"
>
{artifactIcon(item.type)}
<span>{item.label}</span>
<span className="font-semibold">{item.count}</span>
</span>
))}
</div>
) : (
<p className="mb-3 text-xs text-muted-foreground">
{pickAppText(locale, '暂时还没有生成内容。', 'No generated content yet.')}
</p>
)}
<div className="space-y-2">
{view.artifacts.map((artifact) => (
<ArtifactRow key={artifact.artifactId} artifact={artifact} />
))}
</div>
</section>
);
}
import type { TaskTimelineCard } from '@/types';
function ProgressPanel({
view,
cards,
isLive,
onClose,
}: {
view: SessionProgressView;
cards: TaskTimelineCard[];
isLive: boolean;
onClose?: () => void;
}) {
const { locale } = useAppI18n();
@ -260,11 +27,14 @@ function ProgressPanel({
<h2 className="text-base font-semibold text-foreground">
{pickAppText(locale, '当前会话的运行进度', 'Current Session Progress')}
</h2>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '任务列表会自动刷新', 'Task updates refresh automatically')}
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
{isLive ? <Activity className="h-3.5 w-3.5" /> : null}
{isLive
? pickAppText(locale, '任务时间线实时更新', 'Task timeline updates live')
: pickAppText(locale, '与任务详情时间线一致', 'Matches the Task detail timeline')}
</p>
</div>
{onClose && (
{onClose ? (
<button
type="button"
onClick={onClose}
@ -273,28 +43,32 @@ function ProgressPanel({
>
<X className="h-4 w-4" />
</button>
)}
) : null}
</div>
<ScrollArea className="min-h-0 flex-1 px-4 py-4">
<div className="space-y-4 pb-6">
<ProgressHeader view={view} />
<StepList steps={view.steps} />
<ArtifactSection view={view} />
<div className="pb-6">
<TaskTimeline cards={cards} isLive={isLive} showHeader={false} />
</div>
</ScrollArea>
</div>
);
}
export function CurrentSessionProgressSidebar({ view }: { view: SessionProgressView }) {
export function CurrentSessionProgressSidebar({
cards,
isLive,
}: {
cards: TaskTimelineCard[];
isLive: boolean;
}) {
const { locale } = useAppI18n();
const [mobileOpen, setMobileOpen] = React.useState(false);
return (
<>
<aside className="hidden h-full w-[380px] shrink-0 border-l border-[#E6E1DE] xl:flex">
<ProgressPanel view={view} />
<ProgressPanel cards={cards} isLive={isLive} />
</aside>
<button
@ -306,7 +80,7 @@ export function CurrentSessionProgressSidebar({ view }: { view: SessionProgressV
<PanelRightOpen className="h-5 w-5" />
</button>
{mobileOpen && (
{mobileOpen ? (
<div className="fixed inset-0 z-50 xl:hidden">
<button
type="button"
@ -315,10 +89,10 @@ export function CurrentSessionProgressSidebar({ view }: { view: SessionProgressV
aria-label={pickAppText(locale, '关闭进度面板', 'Close progress panel')}
/>
<div className="absolute inset-y-0 right-0 w-[min(92vw,390px)] border-l border-[#E6E1DE] shadow-2xl">
<ProgressPanel view={view} onClose={() => setMobileOpen(false)} />
<ProgressPanel cards={cards} isLive={isLive} onClose={() => setMobileOpen(false)} />
</div>
</div>
)}
) : null}
</>
);
}

View File

@ -10,6 +10,14 @@ import { getTaskCardMessageIndexes, hasVisibleChatContent, normalizedMessageText
import { AgentTeamBlock } from '@/components/chat-workbench/AgentTeamBlock';
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { containedPreservedLongTextClass } from '@/lib/text-wrapping';
@ -58,6 +66,8 @@ function MessageBubble({
const textContent = normalizedMessageText(message.content);
const [feedbackMode, setFeedbackMode] = React.useState<'accept' | null>(null);
const [feedbackComment, setFeedbackComment] = React.useState('');
const [confirmAbandonOpen, setConfirmAbandonOpen] = React.useState(false);
const feedbackTextareaId = message.run_id ? `feedback-note-${message.run_id}` : undefined;
return (
<div className={`flex gap-3 ${isUser ? 'justify-end' : ''}`}>
@ -130,7 +140,7 @@ function MessageBubble({
</div>
<Link
href={`/tasks/${encodeURIComponent(message.task_id)}`}
className="inline-flex h-8 items-center gap-1 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
className="inline-flex h-11 items-center gap-1 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{pickAppText(locale, '查看任务', 'Open task')}
<ChevronRight className="h-3.5 w-3.5" />
@ -157,7 +167,7 @@ function MessageBubble({
<button
type="button"
onClick={() => setFeedbackMode('accept')}
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
className="inline-flex h-11 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ThumbsUp className="h-3.5 w-3.5" />
{pickAppText(locale, '接受', 'Accept')}
@ -165,15 +175,15 @@ function MessageBubble({
<button
type="button"
onClick={() => onRequestRevision(message.run_id!)}
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
className="inline-flex h-11 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<RefreshCcw className="h-3.5 w-3.5" />
{pickAppText(locale, '需要修改', 'Revise')}
</button>
<button
type="button"
onClick={() => onFeedback(message.run_id!, 'abandon')}
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={() => setConfirmAbandonOpen(true)}
className="inline-flex h-11 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<XCircle className="h-3.5 w-3.5" />
{pickAppText(locale, '放弃', 'Abandon')}
@ -181,7 +191,11 @@ function MessageBubble({
</div>
{feedbackMode && (
<div className="space-y-2 rounded-md border border-border bg-background p-2">
<label htmlFor={feedbackTextareaId} className="sr-only">
{pickAppText(locale, '接受反馈备注', 'Acceptance note')}
</label>
<textarea
id={feedbackTextareaId}
value={feedbackComment}
onChange={(event) => setFeedbackComment(event.target.value)}
placeholder={pickAppText(locale, '可选:补充说明...', 'Optional note...')}
@ -194,14 +208,14 @@ function MessageBubble({
setFeedbackMode(null);
setFeedbackComment('');
}}
className="h-8 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent"
className="h-11 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent"
>
{pickAppText(locale, '取消', 'Cancel')}
</button>
<button
type="button"
onClick={() => onFeedback(message.run_id!, feedbackMode, feedbackComment.trim() || undefined)}
className="h-8 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
className="h-11 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{pickAppText(locale, '提交', 'Submit')}
</button>
@ -210,6 +224,35 @@ function MessageBubble({
)}
</>
)}
<Dialog open={confirmAbandonOpen} onOpenChange={setConfirmAbandonOpen}>
<DialogContent className="max-w-[calc(100vw-2rem)] sm:max-w-md">
<DialogHeader>
<DialogTitle>{pickAppText(locale, '放弃当前任务?', 'Abandon this task?')}</DialogTitle>
<DialogDescription>
{pickAppText(locale, '放弃后会停止等待该任务的验收结果,此操作需要明确确认。', 'This stops waiting for this task acceptance result and requires confirmation.')}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-2">
<button
type="button"
onClick={() => setConfirmAbandonOpen(false)}
className="h-11 rounded-md border border-border px-4 text-sm text-muted-foreground hover:bg-accent"
>
{pickAppText(locale, '取消', 'Cancel')}
</button>
<button
type="button"
onClick={() => {
setConfirmAbandonOpen(false);
onFeedback(message.run_id!, 'abandon');
}}
className="h-11 rounded-md bg-destructive px-4 text-sm font-medium text-destructive-foreground hover:bg-destructive/90"
>
{pickAppText(locale, '确认放弃', 'Confirm abandon')}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
{message.feedback_error && (
<span className="text-xs text-destructive">{message.feedback_error}</span>
)}
@ -394,8 +437,8 @@ export function MessageList({
})();
return (
<ScrollArea className="h-full px-8" viewportRef={viewportRef}>
<div className="mx-auto max-w-5xl space-y-8 py-10">
<ScrollArea className="h-full px-3 sm:px-5 md:px-8" viewportRef={viewportRef}>
<div className="mx-auto max-w-5xl space-y-8 py-6 md:py-10">
{visibleMessages.length === 0 && teamGroups.length === 0 && !isThinking && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Bot className="w-12 h-12 mb-4 opacity-50" />

View File

@ -54,27 +54,27 @@ export function SkillDetailView({
}: SkillDetailViewProps) {
const readme = stripFrontmatter(content || '');
return (
<div className="rounded-lg border border-border bg-white text-black [--background:0_0%_100%] [--card:0_0%_100%] [--card-foreground:0_0%_0%] [--foreground:0_0%_0%] [--muted-foreground:0_0%_0%] [--popover:0_0%_100%] [--popover-foreground:0_0%_0%] [--secondary-foreground:0_0%_0%]">
<div className="border-b border-border p-5">
<div className="min-w-0 rounded-lg border border-border bg-white text-black [--background:0_0%_100%] [--card:0_0%_100%] [--card-foreground:0_0%_0%] [--foreground:0_0%_0%] [--muted-foreground:0_0%_0%] [--popover:0_0%_100%] [--popover-foreground:0_0%_0%] [--secondary-foreground:0_0%_0%]">
<div className="border-b border-border p-4 sm:p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<div className="min-w-0 flex-1">
<div className="mb-2 flex flex-wrap items-center gap-2">
{badges}
<Badge variant="outline">v{currentVersion || '-'}</Badge>
{loadingVersion && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
</div>
<h2 className="break-words text-2xl font-semibold tracking-normal">{title}</h2>
{summary && <p className="mt-2 max-w-4xl text-sm leading-6 text-muted-foreground">{summary}</p>}
{summary && <p className="mt-2 max-w-4xl break-words text-sm leading-6 text-muted-foreground">{summary}</p>}
</div>
{actions}
</div>
</div>
<Tabs defaultValue="overview" className="p-5">
<TabsList>
<TabsTrigger value="overview">{labels.overview}</TabsTrigger>
<TabsTrigger value="files">{labels.files}</TabsTrigger>
<TabsTrigger value="versions">{labels.versions}</TabsTrigger>
<Tabs defaultValue="overview" className="p-4 sm:p-5">
<TabsList className="h-auto min-h-11 flex-wrap">
<TabsTrigger value="overview" className="min-h-11">{labels.overview}</TabsTrigger>
<TabsTrigger value="files" className="min-h-11">{labels.files}</TabsTrigger>
<TabsTrigger value="versions" className="min-h-11">{labels.versions}</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-5">
@ -86,7 +86,7 @@ export function SkillDetailView({
</TabsContent>
<TabsContent value="files" className="mt-5">
<div className="grid gap-4 lg:grid-cols-[320px_minmax(0,1fr)]">
<div className="grid min-w-0 gap-4 lg:grid-cols-[minmax(0,320px)_minmax(0,1fr)]">
<div className="min-h-[420px] rounded-lg border border-border">
{files.length === 0 ? (
<EmptyPanel icon={<FileText className="h-7 w-7" />} text={labels.noFiles} />
@ -96,7 +96,7 @@ export function SkillDetailView({
<button
key={file.filePath}
type="button"
className={`flex w-full items-center justify-between gap-3 rounded-md px-3 py-2 text-left text-sm transition hover:bg-muted ${
className={`flex min-h-11 w-full items-center justify-between gap-3 rounded-md px-3 py-3 text-left text-sm transition hover:bg-muted ${
selectedFile?.filePath === file.filePath ? 'bg-muted' : ''
}`}
onClick={() => onOpenFile(file.filePath)}
@ -109,7 +109,7 @@ export function SkillDetailView({
)}
</div>
<div className="min-h-[420px] rounded-lg border border-border bg-muted/20 p-4">
<div className="min-w-0 rounded-lg border border-border bg-muted/20 p-4 lg:min-h-[420px]">
{loadingFile ? (
<div className="flex h-[360px] items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
@ -129,7 +129,7 @@ export function SkillDetailView({
<button
key={version.version}
type="button"
className={`flex w-full items-center justify-between gap-4 rounded-lg border px-4 py-3 text-left transition hover:bg-muted ${
className={`flex min-h-11 w-full flex-col gap-3 rounded-lg border px-4 py-3 text-left transition hover:bg-muted sm:flex-row sm:items-center sm:justify-between sm:gap-4 ${
version.version === currentVersion ? 'border-primary bg-muted' : 'border-border'
}`}
onClick={() => onSelectVersion(version.version)}
@ -137,15 +137,15 @@ export function SkillDetailView({
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<span className="font-mono text-sm">{version.version}</span>
<span className="break-all font-mono text-sm">{version.version}</span>
{version.version === currentVersion && <Badge variant="secondary">{labels.current}</Badge>}
{version.status && <Badge variant="outline">{version.status}</Badge>}
</div>
{version.changeReason && (
<p className="mt-1 truncate text-xs text-muted-foreground">{version.changeReason}</p>
<p className="mt-1 break-words text-xs text-muted-foreground">{version.changeReason}</p>
)}
</div>
<div className="shrink-0 text-right text-xs text-muted-foreground">
<div className="shrink-0 text-left text-xs text-muted-foreground sm:text-right">
{version.publishedAt || version.createdAt || ''}
{typeof version.totalSize === 'number' && (
<div>
@ -165,7 +165,7 @@ export function SkillDetailView({
function FilePreview({ file, labels }: { file: SkillFileContent; labels: SkillDetailViewProps['labels'] }) {
const content = file.content || '';
return (
<div className="space-y-3">
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="break-all font-mono text-sm font-medium">{file.filePath}</div>
<Badge variant="outline">
@ -177,7 +177,7 @@ function FilePreview({ file, labels }: { file: SkillFileContent; labels: SkillDe
) : isMarkdown(file.filePath, file.contentType) ? (
<MarkdownPreview content={stripFrontmatter(content)} />
) : (
<pre className="max-h-[560px] overflow-auto rounded-md border border-border bg-background p-4 text-xs leading-5">
<pre className="max-h-[560px] max-w-full overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-background p-4 text-xs leading-5">
{content}
</pre>
)}
@ -187,7 +187,7 @@ function FilePreview({ file, labels }: { file: SkillFileContent; labels: SkillDe
function MarkdownPreview({ content }: { content: string }) {
return (
<div className="prose prose-sm max-w-none text-black prose-a:text-black prose-blockquote:text-black prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:text-black prose-headings:text-black prose-headings:tracking-normal prose-li:text-black prose-p:text-black prose-pre:border prose-pre:border-border prose-pre:bg-muted prose-pre:text-black prose-strong:text-black prose-table:text-black prose-td:text-black prose-th:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<div className="prose prose-sm max-w-none break-words text-black prose-a:text-black prose-blockquote:text-black prose-code:break-words prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:text-black prose-headings:text-black prose-headings:tracking-normal prose-li:text-black prose-p:text-black prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:border prose-pre:border-border prose-pre:bg-muted prose-pre:text-black prose-strong:text-black prose-table:block prose-table:overflow-x-auto prose-table:text-black prose-td:text-black prose-th:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);

View File

@ -7,6 +7,7 @@ import { TaskRuntimeStatusBadge, formatTaskRuntimeTime } from '@/components/task
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
@ -96,7 +97,7 @@ function FeedbackButton({
const isBusy = actionBusy === type || Boolean(actionBusy?.endsWith(type));
return (
<Button type="button" variant="outline" className="w-full justify-center" disabled={disabled || Boolean(actionBusy)} onClick={onClick}>
<Button type="button" variant="outline" className="h-11 w-full justify-center" disabled={disabled || Boolean(actionBusy)} onClick={onClick}>
{isBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : icon}
{label}
</Button>
@ -156,6 +157,7 @@ export function TaskAcceptanceControls({
onSubmit,
}: Props) {
const { locale } = useAppI18n();
const commentId = React.useId();
const [localComment, setLocalComment] = React.useState('');
const comment = revision ?? localComment;
const setComment = onRevisionChange ?? setLocalComment;
@ -224,12 +226,16 @@ export function TaskAcceptanceControls({
/>
</div>
<Textarea
value={comment}
onChange={(event) => setComment(event.target.value)}
disabled={Boolean(recordedFeedback) || isFinalized || !isReadyForAcceptance || Boolean(actionBusy)}
placeholder={pickAppText(locale, '需要修改时写下具体要求;接受或放弃可选填说明。', 'Describe requested changes; notes are optional for accept or abandon.')}
/>
<div className="space-y-2">
<Label htmlFor={commentId}>{pickAppText(locale, '验收说明', 'Acceptance note')}</Label>
<Textarea
id={commentId}
value={comment}
onChange={(event) => setComment(event.target.value)}
disabled={Boolean(recordedFeedback) || isFinalized || !isReadyForAcceptance || Boolean(actionBusy)}
placeholder={pickAppText(locale, '需要修改时写下具体要求;接受或放弃可选填说明。', 'Describe requested changes; notes are optional for accept or abandon.')}
/>
</div>
<div className={`text-xs text-muted-foreground ${containedPreservedLongTextClass}`}>
{pickAppText(locale, '验收将记录到当前任务运行:', 'Acceptance will be recorded on run: ')}
<span className="font-mono">{runId || '-'}</span>

View File

@ -43,17 +43,17 @@ export function TaskLiveHeader({ task, activeLabel, durationMs, reviewTargetId }
const showReviewLink = Boolean(reviewTargetId && ['awaiting_acceptance', 'needs_revision'].includes(task.status));
return (
<header className="sticky top-0 z-20 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<header className="sticky top-[65px] z-20 min-w-0 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:px-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline" size="sm">
<Button asChild variant="outline" size="sm" className="h-11">
<Link href="/tasks">
<ArrowLeft className="mr-2 h-4 w-4" />
{pickAppText(locale, '返回任务', 'Back to tasks')}
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Button asChild variant="ghost" size="sm" className="h-11">
<Link href="/">
<MessageSquare className="mr-2 h-4 w-4" />
{pickAppText(locale, '对话', 'Chat')}
@ -70,7 +70,7 @@ export function TaskLiveHeader({ task, activeLabel, durationMs, reviewTargetId }
)}
{activeLabel ? <Badge variant="secondary">{activeLabel}</Badge> : null}
{showReviewLink ? (
<Button asChild variant="default" size="sm">
<Button asChild variant="default" size="sm" className="h-11">
<a href={`#${reviewTargetId}`}>
<CheckCircle2 className="mr-2 h-4 w-4" />
{pickAppText(locale, '验收', 'Review')}

View File

@ -131,7 +131,7 @@ export function TaskSideRail({ task, runs, artifacts, cards }: Props) {
const latestAlert = cards.filter(isWarningOrError).sort((a, b) => toTime(b.createdAt) - toTime(a.createdAt))[0] ?? null;
return (
<aside className="space-y-4">
<aside className="min-w-0 space-y-4">
<Card className="rounded-md">
<CardHeader>
<CardTitle className="text-base">{pickAppText(locale, '任务状态', 'Task status')}</CardTitle>
@ -230,14 +230,14 @@ export function TaskSideRail({ task, runs, artifacts, cards }: Props) {
<div className="mt-1 text-xs text-muted-foreground">{artifact.artifact_type}</div>
</div>
{href ? (
<Button asChild size="sm" variant="outline" className="shrink-0">
<Button asChild size="sm" variant="outline" className="h-11 shrink-0">
<a href={href} target="_blank" rel="noopener noreferrer">
{artifact.url ? <ExternalLink className="mr-2 h-3.5 w-3.5" /> : <Download className="mr-2 h-3.5 w-3.5" />}
{artifact.url ? pickAppText(locale, '打开', 'Open') : pickAppText(locale, '下载', 'Download')}
</a>
</Button>
) : inlinePayload ? (
<Button size="sm" variant="outline" className="shrink-0" onClick={() => downloadInlineArtifact(artifact)}>
<Button size="sm" variant="outline" className="h-11 shrink-0" onClick={() => downloadInlineArtifact(artifact)}>
<Download className="mr-2 h-3.5 w-3.5" />
{pickAppText(locale, '下载', 'Download')}
</Button>

View File

@ -14,26 +14,29 @@ type Props = {
isLive: boolean;
resultAcceptance?: TaskResultAcceptance;
reviewTargetId?: string;
showHeader?: boolean;
};
export function TaskTimeline({ cards, isLive, resultAcceptance, reviewTargetId }: Props) {
export function TaskTimeline({ cards, isLive, resultAcceptance, reviewTargetId, showHeader = true }: Props) {
const { locale } = useAppI18n();
return (
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold">{pickAppText(locale, '时间线', 'Timeline')}</h2>
{isLive ? (
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
</span>
<Activity className="h-3.5 w-3.5" />
{pickAppText(locale, '实时更新', 'Live')}
</div>
) : null}
</div>
{showHeader ? (
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold">{pickAppText(locale, '时间线', 'Timeline')}</h2>
{isLive ? (
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
</span>
<Activity className="h-3.5 w-3.5" />
{pickAppText(locale, '实时更新', 'Live')}
</div>
) : null}
</div>
) : null}
{cards.length === 0 ? (
<Card className="rounded-md border-dashed">

View File

@ -148,7 +148,7 @@ function TaskResultHistory({ card }: { card: TaskTimelineCardView }) {
return (
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-muted/20 px-3 py-2 text-sm">
<summary className="flex cursor-pointer select-none items-center justify-between gap-3 font-medium">
<summary className="flex min-h-[44px] cursor-pointer select-none items-center justify-between gap-3 font-medium">
<span>{pickAppText(locale, '展开历史版本', 'Show previous versions')}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</summary>
@ -182,7 +182,7 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
const shouldRenderResultAcceptance = Boolean(card.type === 'result' && resultAcceptance && card.runId === resultAcceptance.runId);
return (
<Card id={shouldRenderResultAcceptance ? reviewTargetId : undefined} className="scroll-mt-28 overflow-hidden rounded-md">
<Card id={shouldRenderResultAcceptance ? reviewTargetId : undefined} className="min-w-0 max-w-full scroll-mt-44 overflow-hidden rounded-md">
<CardContent className="p-4">
<div className="flex gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
@ -224,7 +224,7 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
{card.type === 'result_history' ? <TaskResultHistory card={card} /> : card.details ? (
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-muted/20 px-3 py-2 text-xs">
<summary className="cursor-pointer select-none font-medium text-muted-foreground">
<summary className="flex min-h-[44px] cursor-pointer select-none items-center font-medium text-muted-foreground">
{pickAppText(locale, '详情 JSON', 'Details JSON')}
</summary>
<pre className={`mt-2 max-h-72 overflow-auto text-[11px] leading-5 text-muted-foreground ${containedJsonTextClass}`}>

View File

@ -41,7 +41,7 @@ export function TaskManagementTabs() {
key={tab.href}
href={tab.href}
className={cn(
'inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-colors',
'inline-flex h-11 items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground'

View File

@ -20,10 +20,10 @@ const buttonVariants = cva(
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
default: 'h-11 px-4 py-2',
sm: 'h-11 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
icon: 'h-11 w-11',
},
},
defaultVariants: {

View File

@ -38,13 +38,13 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
'fixed left-4 right-4 top-4 z-50 grid max-h-[calc(100vh-2rem)] w-auto max-w-none gap-4 overflow-x-hidden overflow-y-auto rounded-lg border bg-background p-5 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:left-[50%] sm:right-auto sm:top-[50%] sm:w-full sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:p-6 sm:data-[state=closed]:zoom-out-95 sm:data-[state=open]:zoom-in-95 sm:data-[state=closed]:slide-out-to-left-1/2 sm:data-[state=closed]:slide-out-to-top-[48%] sm:data-[state=open]:slide-in-from-left-1/2 sm:data-[state=open]:slide-in-from-top-[48%] [&>*]:min-w-0',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<DialogPrimitive.Close className="absolute right-3 top-3 flex h-11 w-11 items-center justify-center rounded-full opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>

View File

@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
'flex h-10 w-full rounded-lg border border-transparent bg-input px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/10 disabled:cursor-not-allowed disabled:opacity-50',
'flex h-11 w-full rounded-lg border border-transparent bg-input px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/10 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}

View File

@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
'flex h-11 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}

View File

@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
'peer inline-flex h-11 w-14 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent px-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...props}
@ -19,7 +19,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-7 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>

View File

@ -14,7 +14,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
'inline-flex min-h-11 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
className
)}
{...props}
@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
'inline-flex h-11 min-h-11 items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className
)}
{...props}

View File

@ -8,6 +8,7 @@ import type {
ChatLogsResponse,
BackendTask,
ChatMessage,
ChannelConnectionView,
ChannelConfigDetail,
ChannelConfigPayload,
ChannelConnectorDescriptor,
@ -666,6 +667,10 @@ export async function listChannelConnectors(): Promise<ChannelConnectorDescripto
return fetchJSON('/api/channel-connectors');
}
export async function listChannelConnections(): Promise<ChannelConnectionView[]> {
return fetchJSON('/api/channel-connections');
}
export async function startChannelConnectorSession(
payload: ConnectorSessionStartPayload
): Promise<ConnectorSessionResponse> {

View File

@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { connectorChannelForKind, runtimeBridgeEnabledForPath, visibleConnectorCards } from '@/lib/channel-connector-state';
import type { ChannelConnectorDescriptor, ChannelStatus } from '@/types';
function channel(overrides: Partial<ChannelStatus>): ChannelStatus {
return {
channel_id: 'weixin-main',
kind: 'weixin',
mode: 'polling',
account_id: 'wx-main',
display_name: 'Weixin Main',
enabled: false,
state: 'disabled',
capabilities: [],
connected_peers: 0,
...overrides,
};
}
describe('connector channel cards', () => {
it('does not let a disabled static channel block connector onboarding', () => {
expect(connectorChannelForKind('weixin', [channel({})])).toBeUndefined();
});
it('selects a running connector channel', () => {
const running = channel({ enabled: true, state: 'running' });
expect(connectorChannelForKind('weixin', [running])).toEqual(running);
});
it('selects a running terminal channel', () => {
const running = channel({
channel_id: 'terminal-dev',
kind: 'terminal',
mode: 'websocket',
display_name: 'Terminal Dev',
enabled: true,
state: 'running',
connected_peers: 1,
});
expect(connectorChannelForKind('terminal', [running])).toEqual(running);
});
it('always includes terminal as a local connector card fallback', () => {
const connectors: ChannelConnectorDescriptor[] = [{ kind: 'weixin', authType: 'qr' }, { kind: 'feishu', authType: 'plugin' }];
expect(visibleConnectorCards(connectors).map((connector) => connector.kind)).toEqual(['weixin', 'feishu', 'terminal']);
});
});
describe('app runtime bridge', () => {
it('stays enabled on settings pages so the global connection status is accurate', () => {
expect(runtimeBridgeEnabledForPath('/settings')).toBe(true);
});
});

View File

@ -0,0 +1,28 @@
import type { ChannelConnectorDescriptor, ChannelStatus } from '@/types';
const CONNECTOR_CARD_KINDS = ['weixin', 'feishu', 'terminal'];
export function connectorChannelForKind(kind: string, channels: ChannelStatus[]): ChannelStatus | undefined {
const matches = channels.filter((channel) => channel.kind === kind);
return (
matches.find((channel) => channel.state === 'running') ||
matches.find((channel) => channel.enabled && channel.state !== 'disabled' && channel.state !== 'stopped')
);
}
export function visibleConnectorCards(connectors: ChannelConnectorDescriptor[]): ChannelConnectorDescriptor[] {
const byKind = new Map<string, ChannelConnectorDescriptor>();
connectors.forEach((connector) => {
if (CONNECTOR_CARD_KINDS.includes(connector.kind) && !byKind.has(connector.kind)) {
byKind.set(connector.kind, connector);
}
});
return CONNECTOR_CARD_KINDS.map((kind) => byKind.get(kind) || { kind });
}
export function runtimeBridgeEnabledForPath(_pathname: string): boolean {
return true;
}

View File

@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
listChannelConnections,
getChannelConnectorSession,
listChannelConnectors,
startChannelConnectorSession,
@ -38,6 +39,18 @@ describe('channel connector api', () => {
expect(String(firstFetchCall(fetchMock)[0])).toMatch(/\/api\/channel-connectors$/);
});
it('lists existing channel connections', async () => {
const fetchMock = vi.fn(() =>
mockJsonResponse([{ connection_id: 'conn_1', channel_id: 'weixin-1', kind: 'weixin', status: 'connected' }])
);
globalThis.fetch = fetchMock as typeof fetch;
const connections = await listChannelConnections();
expect(connections[0].status).toBe('connected');
expect(String(firstFetchCall(fetchMock)[0])).toMatch(/\/api\/channel-connections$/);
});
it('starts a connector session with options', async () => {
const fetchMock = vi.fn(() =>
mockJsonResponse({

View File

@ -1,201 +0,0 @@
import { describe, expect, it } from 'vitest';
import { buildSessionProgressView } from '@/lib/session-progress';
import type { ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
describe('session progress view builder', () => {
it('selects the latest active root run for the current session and builds its run tree', () => {
const processRuns: ProcessRun[] = [
{
run_id: 'old-root',
parent_run_id: null,
session_id: 'web:current',
actor_type: 'agent',
actor_id: 'main',
actor_name: 'Main Agent',
title: '旧任务',
status: 'done',
started_at: '2026-05-22T08:00:00.000Z',
finished_at: '2026-05-22T08:05:00.000Z',
},
{
run_id: 'latest-root',
parent_run_id: null,
session_id: 'web:current',
actor_type: 'agent',
actor_id: 'main',
actor_name: 'Main Agent',
title: '销售数据分析报告生成',
status: 'running',
started_at: '2026-05-22T09:00:00.000Z',
metadata: {
step_index: 3,
step_total: 5,
},
},
{
run_id: 'collect-data',
parent_run_id: 'latest-root',
session_id: 'web:current',
actor_type: 'agent',
actor_id: 'collector',
actor_name: 'Data Agent',
title: '收集销售数据',
status: 'done',
started_at: '2026-05-22T09:01:00.000Z',
finished_at: '2026-05-22T09:03:00.000Z',
summary: '已获取 Q1 销售数据',
},
{
run_id: 'clean-data',
parent_run_id: 'latest-root',
session_id: 'web:current',
actor_type: 'agent',
actor_id: 'cleaner',
actor_name: 'Cleaning Agent',
title: '数据清洗与预处理',
status: 'running',
started_at: '2026-05-22T09:04:00.000Z',
},
{
run_id: 'other-root',
parent_run_id: null,
session_id: 'web:other',
actor_type: 'agent',
actor_id: 'main',
actor_name: 'Main Agent',
title: '其他会话任务',
status: 'running',
started_at: '2026-05-22T10:00:00.000Z',
},
];
const processEvents: ProcessEvent[] = [
{
event_id: 'evt-clean',
run_id: 'clean-data',
parent_run_id: 'latest-root',
kind: 'run_progress',
actor_type: 'agent',
actor_id: 'cleaner',
actor_name: 'Cleaning Agent',
text: '清洗缺失值、异常值,统一格式',
created_at: '2026-05-22T09:05:00.000Z',
},
];
const processArtifacts: ProcessArtifact[] = [
{
artifact_id: 'artifact-json',
run_id: 'collect-data',
actor_type: 'agent',
actor_id: 'collector',
actor_name: 'Data Agent',
title: '销售数据',
artifact_type: 'json',
data: { rows: 120 },
created_at: '2026-05-22T09:03:30.000Z',
},
{
artifact_id: 'artifact-markdown',
run_id: 'clean-data',
actor_type: 'agent',
actor_id: 'cleaner',
actor_name: 'Cleaning Agent',
title: '清洗说明',
artifact_type: 'markdown',
content: '已完成数据标准化。',
created_at: '2026-05-22T09:05:30.000Z',
},
{
artifact_id: 'artifact-other-session',
run_id: 'other-root',
actor_type: 'agent',
actor_id: 'main',
title: '其他会话产物',
artifact_type: 'text',
content: '不应出现',
created_at: '2026-05-22T10:01:00.000Z',
},
];
const view = buildSessionProgressView({
sessionId: 'web:current',
processRuns,
processEvents,
processArtifacts,
locale: 'zh-CN',
});
expect(view).not.toBeNull();
expect(view?.rootRunId).toBe('latest-root');
expect(view?.title).toBe('销售数据分析报告生成');
expect(view?.progress).toMatchObject({
value: 3,
max: 5,
percent: 60,
label: '运行中3 / 5 步',
});
expect(view?.steps.map((step) => step.runId)).toEqual(['collect-data', 'clean-data', 'latest-root']);
expect(view?.steps.find((step) => step.runId === 'clean-data')?.description).toBe('清洗缺失值、异常值,统一格式');
expect(view?.artifactTypeSummaries).toEqual([
{ type: 'json', count: 1, label: 'JSON' },
{ type: 'markdown', count: 1, label: 'Markdown' },
]);
expect(view?.artifacts.map((artifact) => artifact.artifactId)).toEqual(['artifact-markdown', 'artifact-json']);
});
it('falls back to completed child run counts when no explicit progress metadata exists', () => {
const processRuns: ProcessRun[] = [
{
run_id: 'root',
parent_run_id: null,
session_id: 'web:current',
actor_type: 'agent',
actor_id: 'main',
actor_name: 'Main Agent',
title: '生成总结',
status: 'running',
started_at: '2026-05-22T09:00:00.000Z',
},
{
run_id: 'done-child',
parent_run_id: 'root',
session_id: 'web:current',
actor_type: 'agent',
actor_id: 'writer',
actor_name: 'Writer',
title: '整理结果',
status: 'done',
started_at: '2026-05-22T09:01:00.000Z',
finished_at: '2026-05-22T09:02:00.000Z',
},
{
run_id: 'running-child',
parent_run_id: 'root',
session_id: 'web:current',
actor_type: 'agent',
actor_id: 'reviewer',
actor_name: 'Reviewer',
title: '复核结果',
status: 'running',
started_at: '2026-05-22T09:03:00.000Z',
},
];
const view = buildSessionProgressView({
sessionId: 'web:current',
processRuns,
processEvents: [],
processArtifacts: [],
locale: 'zh-CN',
});
expect(view?.progress).toMatchObject({
value: 1,
max: 2,
percent: 50,
label: '已完成 1 / 2 步',
});
});
});

View File

@ -1,392 +0,0 @@
import type { ProcessArtifact, ProcessEvent, ProcessRun, ProcessRunStatus } from '@/types';
import { getCurrentAppLocale, pickAppText, type AppLocale } from '@/lib/i18n/core';
const TERMINAL_STATUSES = new Set<ProcessRunStatus>(['done', 'error', 'cancelled']);
const ACTIVE_STATUSES = new Set<ProcessRunStatus>(['queued', 'running', 'waiting']);
const ARTIFACT_TYPE_ORDER: ProcessArtifact['artifact_type'][] = [
'text',
'json',
'file',
'image',
'link',
'markdown',
];
export interface SessionProgressValueView {
label: string;
value: number | null;
max: number | null;
percent: number | null;
}
export interface SessionProgressStepView {
runId: string;
title: string;
actorName: string;
status: ProcessRunStatus;
description: string | null;
startedAt: string;
updatedAt: string;
finishedAt: string | null;
artifactCount: number;
isRoot: boolean;
isCurrent: boolean;
}
export interface SessionProgressArtifactView {
artifactId: string;
runId: string;
title: string;
type: ProcessArtifact['artifact_type'];
typeLabel: string;
actorName: string;
preview: string;
createdAt: string;
url?: string;
}
export interface SessionProgressArtifactTypeSummary {
type: ProcessArtifact['artifact_type'];
count: number;
label: string;
}
export interface SessionProgressView {
rootRunId: string;
title: string;
status: ProcessRunStatus;
summary: string | null;
updatedAt: string;
progress: SessionProgressValueView;
steps: SessionProgressStepView[];
artifacts: SessionProgressArtifactView[];
artifactTypeSummaries: SessionProgressArtifactTypeSummary[];
}
export type BuildSessionProgressInput = {
sessionId: string;
processRuns: ProcessRun[];
processEvents: ProcessEvent[];
processArtifacts: ProcessArtifact[];
locale?: AppLocale;
};
function toTime(value?: string | null): number | null {
if (!value) return null;
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : null;
}
function latestTimestamp(values: Array<string | null | undefined>): string | null {
let selected: string | null = null;
let selectedTime = -1;
for (const value of values) {
const time = toTime(value);
if (time === null || time <= selectedTime) continue;
selected = value ?? null;
selectedTime = time;
}
return selected;
}
function compareIsoDesc(a?: string | null, b?: string | null): number {
return (toTime(b) ?? 0) - (toTime(a) ?? 0);
}
function firstNumber(metadata: Record<string, unknown> | undefined, keys: string[]): number | null {
for (const key of keys) {
const value = metadata?.[key];
if (typeof value === 'number' && Number.isFinite(value)) return value;
}
return null;
}
function buildChildrenMap(processRuns: ProcessRun[]): Map<string, ProcessRun[]> {
const map = new Map<string, ProcessRun[]>();
for (const run of processRuns) {
if (!run.parent_run_id) continue;
const children = map.get(run.parent_run_id);
if (children) {
children.push(run);
} else {
map.set(run.parent_run_id, [run]);
}
}
return map;
}
function collectRunTree(rootRun: ProcessRun, childrenMap: Map<string, ProcessRun[]>): ProcessRun[] {
const collected: ProcessRun[] = [];
const stack = [rootRun];
const seen = new Set<string>();
while (stack.length > 0) {
const current = stack.pop();
if (!current || seen.has(current.run_id)) continue;
seen.add(current.run_id);
collected.push(current);
const children = childrenMap.get(current.run_id) ?? [];
for (let index = children.length - 1; index >= 0; index -= 1) {
stack.push(children[index]);
}
}
return collected;
}
function groupByRunId<T extends { run_id: string }>(items: T[]): Map<string, T[]> {
const map = new Map<string, T[]>();
for (const item of items) {
const existing = map.get(item.run_id);
if (existing) {
existing.push(item);
} else {
map.set(item.run_id, [item]);
}
}
return map;
}
function getRunUpdatedAt(
run: ProcessRun,
eventsByRun: Map<string, ProcessEvent[]>,
artifactsByRun: Map<string, ProcessArtifact[]>,
): string {
return (
latestTimestamp([
run.finished_at,
run.started_at,
...(eventsByRun.get(run.run_id) ?? []).map((event) => event.created_at),
...(artifactsByRun.get(run.run_id) ?? []).map((artifact) => artifact.created_at),
]) ?? run.started_at
);
}
function getTreeUpdatedAt(
runs: ProcessRun[],
eventsByRun: Map<string, ProcessEvent[]>,
artifactsByRun: Map<string, ProcessArtifact[]>,
): string {
return latestTimestamp(runs.map((run) => getRunUpdatedAt(run, eventsByRun, artifactsByRun))) ?? runs[0]?.started_at ?? '';
}
function latestEventText(events: ProcessEvent[]): string | null {
const event = [...events]
.filter((item) => item.text?.trim())
.sort((a, b) => compareIsoDesc(a.created_at, b.created_at))[0];
return event?.text?.trim() || null;
}
function percent(value: number, max: number): number {
return Math.max(0, Math.min(100, Math.round((value / max) * 100)));
}
function explicitProgress(
rootRun: ProcessRun,
treeEvents: ProcessEvent[],
locale: AppLocale,
): SessionProgressValueView | null {
const metadataSources = [
rootRun.metadata,
...[...treeEvents]
.sort((a, b) => compareIsoDesc(a.created_at, b.created_at))
.map((event) => event.metadata),
];
for (const metadata of metadataSources) {
const stepValue = firstNumber(metadata, ['step_index']);
const stepMax = firstNumber(metadata, ['step_total']);
if (stepValue !== null && stepMax !== null && stepMax > 0) {
const safeValue = Math.min(stepValue, stepMax);
return {
label: pickAppText(locale, `运行中:${safeValue} / ${stepMax}`, `Running: ${safeValue} / ${stepMax} steps`),
value: safeValue,
max: stepMax,
percent: percent(safeValue, stepMax),
};
}
const stageValue = firstNumber(metadata, ['stage_index', 'phase_index']);
const stageMax = firstNumber(metadata, ['stage_total', 'phase_total']);
if (stageValue !== null && stageMax !== null && stageMax > 0) {
const safeValue = Math.min(stageValue, stageMax);
return {
label: pickAppText(locale, `运行中:${safeValue} / ${stageMax} 阶段`, `Running: ${safeValue} / ${stageMax} stages`),
value: safeValue,
max: stageMax,
percent: percent(safeValue, stageMax),
};
}
}
return null;
}
function fallbackProgress(taskRuns: ProcessRun[], locale: AppLocale): SessionProgressValueView {
const childRuns = taskRuns.filter((run) => run.parent_run_id);
const runsForProgress = childRuns.length > 0 ? childRuns : taskRuns;
const doneRuns = runsForProgress.filter((run) => run.status === 'done').length;
const totalRuns = runsForProgress.length;
if (totalRuns > 0) {
return {
label: pickAppText(locale, `已完成 ${doneRuns} / ${totalRuns}`, `Completed ${doneRuns} / ${totalRuns} steps`),
value: doneRuns,
max: totalRuns,
percent: percent(doneRuns, totalRuns),
};
}
return {
label: pickAppText(locale, '等待任务数据', 'Waiting for task data'),
value: null,
max: null,
percent: null,
};
}
function artifactTypeLabel(type: ProcessArtifact['artifact_type'], locale: AppLocale): string {
if (type === 'text') return pickAppText(locale, '文本', 'Text');
if (type === 'json') return 'JSON';
if (type === 'file') return pickAppText(locale, '文件', 'File');
if (type === 'image') return pickAppText(locale, '图片', 'Image');
if (type === 'link') return pickAppText(locale, '链接', 'Link');
return 'Markdown';
}
function artifactPreview(artifact: ProcessArtifact, locale: AppLocale): string {
if (artifact.content?.trim()) {
return artifact.content.trim().replace(/\s+/g, ' ').slice(0, 120);
}
if (artifact.url?.trim()) return artifact.url.trim();
if (artifact.data !== undefined) {
return JSON.stringify(artifact.data).slice(0, 120);
}
return pickAppText(locale, '暂无预览', 'No preview');
}
function buildArtifactSummaries(
artifacts: ProcessArtifact[],
locale: AppLocale,
): SessionProgressArtifactTypeSummary[] {
const counts = new Map<ProcessArtifact['artifact_type'], number>();
for (const artifact of artifacts) {
counts.set(artifact.artifact_type, (counts.get(artifact.artifact_type) ?? 0) + 1);
}
return ARTIFACT_TYPE_ORDER
.filter((type) => counts.has(type))
.map((type) => ({
type,
count: counts.get(type) ?? 0,
label: artifactTypeLabel(type, locale),
}));
}
function buildArtifactViews(
artifacts: ProcessArtifact[],
locale: AppLocale,
): SessionProgressArtifactView[] {
return [...artifacts]
.sort((a, b) => compareIsoDesc(a.created_at, b.created_at))
.map((artifact) => ({
artifactId: artifact.artifact_id,
runId: artifact.run_id,
title: artifact.title,
type: artifact.artifact_type,
typeLabel: artifactTypeLabel(artifact.artifact_type, locale),
actorName: artifact.actor_name || artifact.actor_id,
preview: artifactPreview(artifact, locale),
createdAt: artifact.created_at,
url: artifact.url,
}));
}
function buildSteps(
rootRun: ProcessRun,
taskRuns: ProcessRun[],
eventsByRun: Map<string, ProcessEvent[]>,
artifactsByRun: Map<string, ProcessArtifact[]>,
): SessionProgressStepView[] {
return [...taskRuns]
.sort((a, b) => {
if (a.run_id === rootRun.run_id) return 1;
if (b.run_id === rootRun.run_id) return -1;
return (toTime(a.started_at) ?? 0) - (toTime(b.started_at) ?? 0);
})
.map((run) => {
const runEvents = eventsByRun.get(run.run_id) ?? [];
const runArtifacts = artifactsByRun.get(run.run_id) ?? [];
return {
runId: run.run_id,
title: run.title,
actorName: run.actor_name,
status: run.status,
description: latestEventText(runEvents) || run.summary?.trim() || null,
startedAt: run.started_at,
updatedAt: getRunUpdatedAt(run, eventsByRun, artifactsByRun),
finishedAt: run.finished_at ?? null,
artifactCount: runArtifacts.length,
isRoot: run.run_id === rootRun.run_id,
isCurrent: !TERMINAL_STATUSES.has(run.status),
};
});
}
export function buildSessionProgressView({
sessionId,
processRuns,
processEvents,
processArtifacts,
locale = getCurrentAppLocale(),
}: BuildSessionProgressInput): SessionProgressView | null {
const sessionRuns = processRuns.filter((run) => run.session_id === sessionId);
const rootRuns = sessionRuns.filter((run) => !run.parent_run_id);
if (rootRuns.length === 0) return null;
const allChildrenMap = buildChildrenMap(processRuns);
const runTreeCache = new Map<string, ProcessRun[]>();
const treeForRoot = (root: ProcessRun) => {
const cached = runTreeCache.get(root.run_id);
if (cached) return cached;
const tree = collectRunTree(root, allChildrenMap).filter(
(run) => run.session_id === sessionId || run.run_id === root.run_id
);
runTreeCache.set(root.run_id, tree);
return tree;
};
const allEventsByRun = groupByRunId(processEvents);
const allArtifactsByRun = groupByRunId(processArtifacts);
const selectedRoot = [...rootRuns].sort((a, b) => {
const aActive = ACTIVE_STATUSES.has(a.status);
const bActive = ACTIVE_STATUSES.has(b.status);
if (aActive !== bActive) return aActive ? -1 : 1;
return compareIsoDesc(
getTreeUpdatedAt(treeForRoot(a), allEventsByRun, allArtifactsByRun),
getTreeUpdatedAt(treeForRoot(b), allEventsByRun, allArtifactsByRun)
);
})[0];
if (!selectedRoot) return null;
const taskRuns = treeForRoot(selectedRoot);
const taskRunIds = new Set(taskRuns.map((run) => run.run_id));
const taskEvents = processEvents.filter((event) => taskRunIds.has(event.run_id));
const taskArtifacts = processArtifacts.filter((artifact) => taskRunIds.has(artifact.run_id));
const eventsByRun = groupByRunId(taskEvents);
const artifactsByRun = groupByRunId(taskArtifacts);
const updatedAt = getTreeUpdatedAt(taskRuns, eventsByRun, artifactsByRun);
const progress = explicitProgress(selectedRoot, taskEvents, locale) ?? fallbackProgress(taskRuns, locale);
return {
rootRunId: selectedRoot.run_id,
title: selectedRoot.title,
status: selectedRoot.status,
summary: selectedRoot.summary?.trim() || latestEventText(eventsByRun.get(selectedRoot.run_id) ?? []) || null,
updatedAt,
progress,
steps: buildSteps(selectedRoot, taskRuns, eventsByRun, artifactsByRun),
artifacts: buildArtifactViews(taskArtifacts, locale),
artifactTypeSummaries: buildArtifactSummaries(taskArtifacts, locale),
};
}

View File

@ -0,0 +1,160 @@
import { describe, expect, it } from 'vitest';
import { selectTaskProcess } from '@/lib/task-process';
import type { BackendTask, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
function task(): BackendTask {
return {
task_id: 'task-1',
session_id: 'web:default',
description: 'Build report',
goal: 'Build report',
constraints: [],
priority: 0,
status: 'running',
creator: 'user',
created_at: '2026-06-04T00:00:00.000Z',
updated_at: '2026-06-04T00:01:00.000Z',
run_ids: ['main-run'],
skill_names: [],
feedback: [],
metadata: {},
process_runs: [
{
run_id: 'main-run',
session_id: 'web:default',
actor_type: 'agent',
actor_id: 'main',
actor_name: 'Main Agent',
title: 'Persisted main run',
status: 'waiting',
started_at: '2026-06-04T00:00:10.000Z',
metadata: { task_id: 'task-1' },
},
],
process_events: [
{
event_id: 'persisted-event',
run_id: 'main-run',
kind: 'task_planned',
actor_type: 'system',
actor_id: 'planner',
actor_name: 'Planner',
text: 'Persisted plan',
created_at: '2026-06-04T00:00:20.000Z',
metadata: { task_id: 'task-1' },
},
],
process_artifacts: [],
};
}
describe('selectTaskProcess', () => {
it('merges persisted and live task process data while excluding other tasks', () => {
const liveRuns: ProcessRun[] = [
{
run_id: 'main-run',
session_id: 'web:default',
actor_type: 'agent',
actor_id: 'main',
actor_name: 'Main Agent',
title: 'Live main run',
status: 'running',
started_at: '2026-06-04T00:00:10.000Z',
metadata: { task_id: 'task-1' },
},
{
run_id: 'child-run',
parent_run_id: 'main-run',
session_id: 'subagent:child',
actor_type: 'agent',
actor_id: 'child',
actor_name: 'Child Agent',
title: 'Child work',
status: 'done',
started_at: '2026-06-04T00:00:30.000Z',
},
{
run_id: 'other-run',
session_id: 'web:default',
actor_type: 'agent',
actor_id: 'other',
actor_name: 'Other Agent',
title: 'Other task',
status: 'running',
started_at: '2026-06-04T00:00:40.000Z',
metadata: { task_id: 'task-2' },
},
];
const liveEvents: ProcessEvent[] = [
{
event_id: 'child-event',
run_id: 'child-run',
parent_run_id: 'main-run',
kind: 'run_progress',
actor_type: 'agent',
actor_id: 'child',
actor_name: 'Child Agent',
text: 'Child finished',
created_at: '2026-06-04T00:00:50.000Z',
},
{
event_id: 'other-event',
run_id: 'other-run',
kind: 'run_progress',
actor_type: 'agent',
actor_id: 'other',
actor_name: 'Other Agent',
text: 'Other task progress',
created_at: '2026-06-04T00:00:55.000Z',
metadata: { task_id: 'task-2' },
},
];
const liveArtifacts: ProcessArtifact[] = [
{
artifact_id: 'child-artifact',
run_id: 'child-run',
actor_type: 'agent',
actor_id: 'child',
actor_name: 'Child Agent',
title: 'Child result',
artifact_type: 'text',
created_at: '2026-06-04T00:01:00.000Z',
},
{
artifact_id: 'other-artifact',
run_id: 'other-run',
actor_type: 'agent',
actor_id: 'other',
title: 'Other result',
artifact_type: 'text',
created_at: '2026-06-04T00:01:05.000Z',
},
];
const selected = selectTaskProcess({
task: task(),
liveRuns,
liveEvents,
liveArtifacts,
});
expect(selected.runs.map((run) => run.run_id)).toEqual(['main-run', 'child-run']);
expect(selected.runs[0].title).toBe('Live main run');
expect(selected.events.map((event) => event.event_id)).toEqual(['persisted-event', 'child-event']);
expect(selected.artifacts.map((artifact) => artifact.artifact_id)).toEqual(['child-artifact']);
});
it('returns persisted task process data when no live data is available', () => {
const selected = selectTaskProcess({
task: task(),
liveRuns: [],
liveEvents: [],
liveArtifacts: [],
});
expect(selected.runs.map((run) => run.run_id)).toEqual(['main-run']);
expect(selected.events.map((event) => event.event_id)).toEqual(['persisted-event']);
expect(selected.artifacts).toEqual([]);
});
});

View File

@ -0,0 +1,75 @@
import type { BackendTask, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
export type TaskProcessSelection = {
runs: ProcessRun[];
events: ProcessEvent[];
artifacts: ProcessArtifact[];
};
export type SelectTaskProcessInput = {
task: BackendTask;
liveRuns?: ProcessRun[];
liveEvents?: ProcessEvent[];
liveArtifacts?: ProcessArtifact[];
};
function mergeById<T>(
persisted: T[],
live: T[],
idFor: (item: T) => string,
): T[] {
const merged = new Map<string, T>();
for (const item of persisted) merged.set(idFor(item), item);
for (const item of live) merged.set(idFor(item), item);
return Array.from(merged.values());
}
function runBelongsToTask(run: ProcessRun, taskId: string, runIds: Set<string>): boolean {
return runIds.has(run.run_id) || run.metadata?.task_id === taskId;
}
function expandTaskRunIds(runs: ProcessRun[], taskId: string, seedRunIds: Set<string>): Set<string> {
const selected = new Set(seedRunIds);
for (const run of runs) {
if (run.metadata?.task_id === taskId) selected.add(run.run_id);
}
let changed = true;
while (changed) {
changed = false;
for (const run of runs) {
if (!run.parent_run_id || !selected.has(run.parent_run_id) || selected.has(run.run_id)) continue;
selected.add(run.run_id);
changed = true;
}
}
return selected;
}
export function selectTaskProcess({
task,
liveRuns = [],
liveEvents = [],
liveArtifacts = [],
}: SelectTaskProcessInput): TaskProcessSelection {
const persistedRuns = task.process_runs ?? [];
const persistedEvents = task.process_events ?? [];
const persistedArtifacts = task.process_artifacts ?? [];
const allRuns = mergeById(persistedRuns, liveRuns, (run) => run.run_id);
const seedRunIds = new Set([
...task.run_ids.filter(Boolean),
...persistedRuns.map((run) => run.run_id),
]);
const taskRunIds = expandTaskRunIds(allRuns, task.task_id, seedRunIds);
const runs = allRuns.filter((run) => runBelongsToTask(run, task.task_id, taskRunIds));
return {
runs,
events: mergeById(persistedEvents, liveEvents, (event) => event.event_id).filter(
(event) => taskRunIds.has(event.run_id) || event.metadata?.task_id === task.task_id
),
artifacts: mergeById(persistedArtifacts, liveArtifacts, (artifact) => artifact.artifact_id).filter(
(artifact) => taskRunIds.has(artifact.run_id) || artifact.metadata?.task_id === task.task_id
),
};
}

View File

@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { buildTaskTimelineView } from '@/lib/task-timeline-view';
import type { BackendTask, ProcessEvent } from '@/types';
function task(): BackendTask {
return {
task_id: 'task-1',
session_id: 'web:default',
description: 'Build report',
goal: 'Build report',
constraints: [],
priority: 0,
status: 'running',
creator: 'user',
created_at: '2026-06-04T00:00:00.000Z',
updated_at: '2026-06-04T00:01:00.000Z',
run_ids: ['main-run'],
skill_names: [],
feedback: [],
metadata: {},
};
}
describe('buildTaskTimelineView', () => {
it('builds canonical task timeline cards from matching live process data', () => {
const liveEvents: ProcessEvent[] = [
{
event_id: 'plan',
run_id: 'main-run',
kind: 'task_planned',
actor_type: 'system',
actor_id: 'planner',
actor_name: 'Planner',
text: 'Plan created',
created_at: '2026-06-04T00:00:10.000Z',
},
];
const view = buildTaskTimelineView({
task: task(),
liveEvents,
});
expect(view?.cards.map((card) => card.type)).toEqual(['task_created', 'plan']);
expect(view?.process.events.map((event) => event.event_id)).toEqual(['plan']);
});
it('returns null when there is no active task to display', () => {
expect(buildTaskTimelineView({ task: null })).toBeNull();
});
});

View File

@ -0,0 +1,37 @@
import { selectTaskProcess, type SelectTaskProcessInput, type TaskProcessSelection } from '@/lib/task-process';
import { buildTaskTimelineCards } from '@/lib/task-timeline';
import type { BackendTask, TaskTimelineCard } from '@/types';
export type BuildTaskTimelineViewInput = Omit<SelectTaskProcessInput, 'task'> & {
task: BackendTask | null;
};
export type TaskTimelineView = {
process: TaskProcessSelection;
cards: TaskTimelineCard[];
};
export function buildTaskTimelineView({
task,
liveRuns,
liveEvents,
liveArtifacts,
}: BuildTaskTimelineViewInput): TaskTimelineView | null {
if (!task) return null;
const process = selectTaskProcess({
task,
liveRuns,
liveEvents,
liveArtifacts,
});
return {
process,
cards: buildTaskTimelineCards({
task,
processRuns: process.runs,
processEvents: process.events,
processArtifacts: process.artifacts,
}),
};
}

View File

@ -0,0 +1,9 @@
{
"status": "failed",
"failedTests": [
"f5227d990c583bf1cb5e-1c04b5ceddc2ff622275",
"f5227d990c583bf1cb5e-960e0b45f6e36c90c9ae",
"f5227d990c583bf1cb5e-205bf022968074780a04",
"f5227d990c583bf1cb5e-ebfd66136ede563d8670"
]
}

View File

@ -0,0 +1,281 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: market-settings-qa.spec.js >> marketplace and settings QA >> marketplace search detail file install flow works
- Location: ../../../../../../tmp/beaver-market-settings-qa/market-settings-qa.spec.js:188:3
# Error details
```
Error: expect(locator).toBeVisible() failed
Locator: getByText(/src\/very-long-ui-token/)
Expected: visible
Error: strict mode violation: getByText(/src\/very-long-ui-token/) resolved to 3 elements:
1) <span class="min-w-0 break-all font-mono text-xs">src/very-long-ui-token-0123456789abcdefghijklmnop…</span> aka getByRole('button', { name: 'src/very-long-ui-token-' })
2) <div class="break-all font-mono text-sm font-medium">src/very-long-ui-token-0123456789abcdefghijklmnop…</div> aka getByText('src/very-long-ui-token-').nth(1)
3) <h1>src/very-long-ui-token-0123456789abcdefghijklmnop…</h1> aka getByRole('heading', { name: 'src/very-long-ui-token-' })
Call log:
- Expect "toBeVisible" with timeout 8000ms
- waiting for getByText(/src\/very-long-ui-token/)
```
# Page snapshot
```yaml
- generic [ref=e1]:
- generic [ref=e2]:
- banner [ref=e3]:
- generic [ref=e5]:
- generic [ref=e6]:
- button "Open navigation" [ref=e7] [cursor=pointer]:
- img [ref=e8]
- link "Beaver" [ref=e9] [cursor=pointer]:
- /url: /
- generic [ref=e10]: Beaver
- generic [ref=e12]:
- generic [ref=e13]:
- img [ref=e14]
- button "ZH" [ref=e18] [cursor=pointer]
- button "EN" [ref=e19] [cursor=pointer]
- button "Open account menu" [ref=e20] [cursor=pointer]:
- generic [ref=e22]: U
- main [ref=e23]:
- generic [ref=e24]:
- generic [ref=e26]:
- generic [ref=e27]:
- img [ref=e28]
- textbox "Search skills..." [ref=e31]: qa
- button "Search" [ref=e32] [cursor=pointer]
- generic [ref=e33]:
- button "Back to search" [ref=e34] [cursor=pointer]:
- img [ref=e35]
- text: Back to search
- generic [ref=e37]:
- generic [ref=e39]:
- generic [ref=e40]:
- generic [ref=e41]:
- generic [ref=e42]: "@team-very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789"
- generic [ref=e43]: "Downloads: 12345"
- generic [ref=e44]: "Stars: 7"
- generic [ref=e45]: v1.0.0-alpha-long-version-name-0123456789
- heading "SkillHub very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789" [level=2] [ref=e46]
- paragraph [ref=e47]: 这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。 https://example.com/very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789
- button "Install" [ref=e48] [cursor=pointer]:
- img [ref=e49]
- text: Install
- generic [ref=e52]:
- tablist [ref=e53]:
- tab "Overview" [ref=e54] [cursor=pointer]
- tab "Files" [selected] [ref=e55] [cursor=pointer]
- tab "Versions" [ref=e56] [cursor=pointer]
- tabpanel "Files" [ref=e57]:
- generic [ref=e58]:
- generic [ref=e60]:
- button "SKILL.md 2.0 KB" [ref=e61] [cursor=pointer]:
- generic [ref=e62]: SKILL.md
- generic [ref=e63]: 2.0 KB
- button "src/very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789/implementation.ts 4.0 KB" [active] [ref=e64] [cursor=pointer]:
- generic [ref=e65]: src/very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789/implementation.ts
- generic [ref=e66]: 4.0 KB
- generic [ref=e68]:
- generic [ref=e69]:
- generic [ref=e70]: src/very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789/implementation.ts
- generic [ref=e71]: "Size: 2.0 KB"
- generic [ref=e72]:
- heading "src/very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789/implementation.ts" [level=1] [ref=e73]
- paragraph [ref=e74]: 这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。
- paragraph [ref=e75]:
- code [ref=e76]: "`very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789`"
- alert [ref=e77]
```
# Test source
```ts
99 | });
100 | await page.route('**/api/marketplaces/skills/search**', async (route) => {
101 | callLog.push({ method: route.request().method(), url: route.request().url() });
102 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [skillItem()], total: 1, page: 0, size: 12 }) });
103 | });
104 | await page.route('**/api/marketplaces/skills/*/*/versions/*/file**', async (route) => {
105 | const url = new URL(route.request().url());
106 | const filePath = url.searchParams.get('path') || 'SKILL.md';
107 | callLog.push({ method: route.request().method(), url: route.request().url(), filePath });
108 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ filePath, fileSize: 2000, contentType: 'text/markdown', isBinary: false, content: `# ${filePath}\n\n${LONG_TEXT}\n\n\`${LONG}${LONG}\`` }) });
109 | });
110 | await page.route('**/api/marketplaces/skills/*/*/versions/*', async (route) => {
111 | callLog.push({ method: route.request().method(), url: route.request().url() });
112 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ detail: { version: '1.0.0-alpha-long-version-name-0123456789', status: 'published', parsedMetadataJson: JSON.stringify({ body: `# Skill\n\n${LONG_TEXT}` }) }, files: [{ filePath: 'SKILL.md', fileSize: 2048, contentType: 'text/markdown' }, { filePath: `src/${LONG}/implementation.ts`, fileSize: 4096, contentType: 'text/plain' }] }) });
113 | });
114 | await page.route('**/api/marketplaces/skills/*/*/versions', async (route) => {
115 | callLog.push({ method: route.request().method(), url: route.request().url() });
116 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [{ version: '1.0.0-alpha-long-version-name-0123456789', status: 'published', createdAt: '2026-06-04T08:00:00Z', changeReason: LONG_TEXT }], total: 1, page: 0, size: 20 }) });
117 | });
118 | await page.route('**/api/marketplaces/skills/*/*/install', async (route) => {
119 | callLog.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON?.() });
120 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, skill_name: `skill-${LONG}`, version: '1.0.0-alpha-long-version-name-0123456789', source: 'skillhub', namespace: `team-${LONG}` }) });
121 | });
122 | await page.route('**/api/marketplaces/skills/*/*', async (route) => {
123 | callLog.push({ method: route.request().method(), url: route.request().url() });
124 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(skillItem()) });
125 | });
126 | await page.route('**/api/status', async (route) => {
127 | callLog.push({ method: route.request().method(), url: route.request().url() });
128 | if (statusError) {
129 | await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ detail: 'QA status error' }) });
130 | return;
131 | }
132 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(statusPayload()) });
133 | });
134 | await page.route('**/api/providers/*/config', async (route) => {
135 | callLog.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON?.() });
136 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
137 | });
138 | await page.route('**/api/agent/config', async (route) => {
139 | callLog.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON?.() });
140 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
141 | });
142 | await page.route('**/api/channels/*/config', async (route) => {
143 | const body = route.request().postDataJSON?.();
144 | callLog.push({ method: route.request().method(), url: route.request().url(), body });
145 | if (route.request().method() === 'GET') {
146 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ channel_id: `telegram-${LONG}`, enabled: true, kind: 'telegram', mode: 'polling', account_id: `bot-${LONG}`, display_name: `Telegram ${LONG}`, config: { requireMentionInGroups: true }, secrets: { botToken: '***' } }) });
147 | } else {
148 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, channel_id: `telegram-${LONG}`, restart_required: true, channel: { channel_id: `telegram-${LONG}`, enabled: true, kind: 'telegram', mode: 'polling', account_id: `bot-${LONG}`, display_name: `Telegram ${LONG}`, config: body?.config || {}, secrets: {} } }) });
149 | }
150 | });
151 | await page.route('**/api/channels/*/events**', async (route) => {
152 | callLog.push({ method: route.request().method(), url: route.request().url() });
153 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ event_id: `event-${LONG}`, channel_id: `telegram-${LONG}`, kind: 'message', status: `ok-${LONG}`, error: null, created_at: '2026-06-04T08:00:00Z' }]) });
154 | });
155 | await page.route('**/api/channel-connectors', async (route) => {
156 | callLog.push({ method: route.request().method(), url: route.request().url() });
157 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ kind: 'terminal', displayName: 'Terminal' }, { kind: 'telegram', displayName: 'Telegram' }, { kind: 'feishu', displayName: 'Feishu/Lark', authType: 'plugin' }]) });
158 | });
159 | await page.route('**/api/channel-connections', async (route) => {
160 | callLog.push({ method: route.request().method(), url: route.request().url() });
161 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ connection_id: `conn-${LONG}`, channel_id: `telegram-${LONG}`, kind: 'telegram', mode: 'polling', display_name: 'Telegram', account_id: `bot-${LONG}`, status: 'connected', auth_type: 'token' }]) });
162 | });
163 | await page.route('**/api/runtime/restart', async (route) => {
164 | callLog.push({ method: route.request().method(), url: route.request().url() });
165 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
166 | });
167 | }
168 |
169 | async function collectMetrics(page) {
170 | return await page.evaluate(() => {
171 | const viewportWidth = window.innerWidth;
172 | const bodyWidth = document.body.scrollWidth;
173 | const docWidth = document.documentElement.scrollWidth;
174 | const overflowElements = Array.from(document.querySelectorAll('body *')).map((el) => {
175 | const rect = el.getBoundingClientRect();
176 | return { tag: el.tagName, text: (el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 100), left: rect.left, right: rect.right, width: rect.width, className: String(el.className || '') };
177 | }).filter((item) => item.width > 1 && (item.left < -1 || item.right > viewportWidth + 1)).slice(0, 30);
178 | const smallTargets = Array.from(document.querySelectorAll('button, a, input:not([type=hidden]), textarea, [role="button"], [role="switch"], [role="combobox"], [role="tab"]')).map((el) => {
179 | const rect = el.getBoundingClientRect();
180 | const style = window.getComputedStyle(el);
181 | return { tag: el.tagName, text: (el.textContent || el.getAttribute('aria-label') || el.getAttribute('title') || '').trim().slice(0, 100), width: Math.round(rect.width), height: Math.round(rect.height), visible: style.visibility !== 'hidden' && style.display !== 'none' && Number(style.opacity) > 0.01 && rect.width > 0 && rect.height > 0 };
182 | }).filter((item) => item.visible && (item.width < 44 || item.height < 44)).slice(0, 30);
183 | return { viewportWidth, bodyWidth, docWidth, horizontalOverflow: Math.max(bodyWidth, docWidth) > viewportWidth + 1, overflowElements, smallTargets };
184 | });
185 | }
186 |
187 | test.describe('marketplace and settings QA', () => {
188 | test('marketplace search detail file install flow works', async ({ page }) => {
189 | await page.setViewportSize({ width: 390, height: 844 });
190 | await installRoutes(page);
191 | await page.goto(`${APP}/marketplace`);
192 | await expect(page.getByPlaceholder(/搜索技能|Search skills/)).toBeVisible();
193 | await page.getByPlaceholder(/搜索技能|Search skills/).fill('qa');
194 | await page.getByRole('button', { name: /搜索|Search/ }).click();
195 | await page.getByText(/SkillHub very-long-ui-token/).click();
196 | await expect(page.getByRole('heading', { name: /SkillHub very-long-ui-token/ })).toBeVisible();
197 | await page.getByRole('tab', { name: /文件|Files/ }).click();
198 | await page.getByRole('button', { name: /src\/very-long-ui-token/ }).click();
> 199 | await expect(page.getByText(/src\/very-long-ui-token/)).toBeVisible();
| ^ Error: expect(locator).toBeVisible() failed
200 | await page.getByRole('button', { name: /安装|Install/ }).click();
201 | await expect.poll(() => callLog.some((call) => call.url.includes('/install') && call.method === 'POST')).toBe(true);
202 | await page.screenshot({ path: path.join(SHOT_DIR, 'market-detail-390x844.png'), fullPage: true });
203 | });
204 |
205 | test('settings agent provider channel and restart flows work', async ({ page }) => {
206 | await page.setViewportSize({ width: 390, height: 844 });
207 | await installRoutes(page);
208 | await page.goto(`${APP}/settings`);
209 | await expect(page.getByRole('heading', { name: /配置|Settings/ })).toBeVisible();
210 | await page.getByLabel(/温度|Temperature/).fill('0.3');
211 | await page.getByRole('button', { name: /保存智能体配置|Save agent config/ }).click();
212 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/agent/config') && call.method !== 'GET')).toBe(true);
213 | await page.getByRole('button', { name: /OpenAI/ }).click();
214 | await expect(page.getByRole('dialog')).toBeVisible();
215 | await page.getByLabel(/请求超时|Request timeout/).fill('60');
216 | await page.getByRole('button', { name: /保存|Save/ }).click();
217 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/providers/openai/config'))).toBe(true);
218 | await page.getByRole('button', { name: /Telegram/ }).click();
219 | await expect(page.getByRole('dialog')).toBeVisible();
220 | await page.getByLabel(/显示名|Display name/).fill(`Telegram ${LONG}`);
221 | await page.getByRole('button', { name: /保存通道配置|Save channel config/ }).click();
222 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/channels/') && call.method !== 'GET')).toBe(true);
223 | await page.screenshot({ path: path.join(SHOT_DIR, 'settings-channel-390x844.png'), fullPage: true });
224 | await page.keyboard.press('Escape');
225 | await page.getByRole('button', { name: /重启实例|Restart instance/ }).first().click();
226 | await expect(page.getByRole('dialog')).toBeVisible();
227 | await page.getByRole('button', { name: /^重启$|^Restart$/ }).click();
228 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/runtime/restart'))).toBe(true);
229 | });
230 |
231 | test('settings error state is readable', async ({ page }) => {
232 | await page.setViewportSize({ width: 390, height: 844 });
233 | await installRoutes(page, { statusError: true });
234 | await page.goto(`${APP}/settings`);
235 | await expect(page.getByText(/QA status error|无法连接|Unable to connect/)).toBeVisible();
236 | await page.screenshot({ path: path.join(SHOT_DIR, 'settings-error-390x844.png'), fullPage: true });
237 | const metrics = await collectMetrics(page);
238 | expect(metrics.horizontalOverflow, JSON.stringify(metrics.overflowElements, null, 2)).toBe(false);
239 | });
240 |
241 | test('responsive layouts have no page overflow or visible small targets', async ({ browser }) => {
242 | const results = [];
243 | for (const viewport of [
244 | { width: 320, height: 568 },
245 | { width: 390, height: 844 },
246 | { width: 844, height: 390 },
247 | { width: 768, height: 1024 },
248 | { width: 1365, height: 900 },
249 | ]) {
250 | const market = await browser.newPage({ viewport });
251 | await installRoutes(market);
252 | await market.goto(`${APP}/marketplace`);
253 | await market.getByText(/SkillHub very-long-ui-token/).click();
254 | await market.screenshot({ path: path.join(SHOT_DIR, `market-${viewport.width}x${viewport.height}.png`), fullPage: true });
255 | const marketMetrics = await collectMetrics(market);
256 | await market.close();
257 |
258 | const settings = await browser.newPage({ viewport });
259 | await installRoutes(settings);
260 | await settings.goto(`${APP}/settings`);
261 | await settings.getByRole('button', { name: /Telegram/ }).click();
262 | await settings.screenshot({ path: path.join(SHOT_DIR, `settings-${viewport.width}x${viewport.height}.png`), fullPage: true });
263 | const settingsMetrics = await collectMetrics(settings);
264 | await settings.close();
265 |
266 | results.push({ viewport, marketMetrics, settingsMetrics });
267 | expect(marketMetrics.horizontalOverflow, JSON.stringify(marketMetrics.overflowElements, null, 2)).toBe(false);
268 | expect(settingsMetrics.horizontalOverflow, JSON.stringify(settingsMetrics.overflowElements, null, 2)).toBe(false);
269 | expect(marketMetrics.smallTargets, JSON.stringify(marketMetrics.smallTargets, null, 2)).toEqual([]);
270 | expect(settingsMetrics.smallTargets, JSON.stringify(settingsMetrics.smallTargets, null, 2)).toEqual([]);
271 | }
272 | fs.writeFileSync(REPORT, JSON.stringify({ results }, null, 2));
273 | });
274 | });
275 |
```

View File

@ -0,0 +1,208 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: market-settings-qa.spec.js >> marketplace and settings QA >> settings error state is readable
- Location: ../../../../../../tmp/beaver-market-settings-qa/market-settings-qa.spec.js:231:3
# Error details
```
Error: expect(locator).toBeVisible() failed
Locator: getByText(/QA status error|无法连接|Unable to connect/)
Expected: visible
Error: strict mode violation: getByText(/QA status error|无法连接|Unable to connect/) resolved to 2 elements:
1) <p class="font-medium">Unable to connect to the Boardware Agent Sandbox …</p> aka getByText('Unable to connect to the')
2) <p class="text-sm text-muted-foreground mt-1">API error 500: QA status error</p> aka getByText('API error 500: QA status error')
Call log:
- Expect "toBeVisible" with timeout 8000ms
- waiting for getByText(/QA status error|无法连接|Unable to connect/)
```
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- generic [ref=e2]:
- banner [ref=e3]:
- generic [ref=e5]:
- generic [ref=e6]:
- button "Open navigation" [ref=e7] [cursor=pointer]:
- img [ref=e8]
- link "Beaver" [ref=e9] [cursor=pointer]:
- /url: /
- generic [ref=e10]: Beaver
- generic [ref=e12]:
- generic [ref=e13]:
- img [ref=e14]
- button "ZH" [ref=e18] [cursor=pointer]
- button "EN" [ref=e19] [cursor=pointer]
- button "Open account menu" [ref=e20] [cursor=pointer]:
- generic [ref=e22]: U
- main [ref=e23]:
- generic [ref=e26]:
- generic [ref=e27]:
- img [ref=e28]
- generic [ref=e30]:
- paragraph [ref=e31]: Unable to connect to the Boardware Agent Sandbox backend
- paragraph [ref=e32]: "API error 500: QA status error"
- paragraph [ref=e33]: Please confirm the backend service is running and reachable from this page.
- button "Retry" [ref=e34] [cursor=pointer]:
- img [ref=e35]
- text: Retry
- alert [ref=e40]
```
# Test source
```ts
135 | callLog.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON?.() });
136 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
137 | });
138 | await page.route('**/api/agent/config', async (route) => {
139 | callLog.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON?.() });
140 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
141 | });
142 | await page.route('**/api/channels/*/config', async (route) => {
143 | const body = route.request().postDataJSON?.();
144 | callLog.push({ method: route.request().method(), url: route.request().url(), body });
145 | if (route.request().method() === 'GET') {
146 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ channel_id: `telegram-${LONG}`, enabled: true, kind: 'telegram', mode: 'polling', account_id: `bot-${LONG}`, display_name: `Telegram ${LONG}`, config: { requireMentionInGroups: true }, secrets: { botToken: '***' } }) });
147 | } else {
148 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, channel_id: `telegram-${LONG}`, restart_required: true, channel: { channel_id: `telegram-${LONG}`, enabled: true, kind: 'telegram', mode: 'polling', account_id: `bot-${LONG}`, display_name: `Telegram ${LONG}`, config: body?.config || {}, secrets: {} } }) });
149 | }
150 | });
151 | await page.route('**/api/channels/*/events**', async (route) => {
152 | callLog.push({ method: route.request().method(), url: route.request().url() });
153 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ event_id: `event-${LONG}`, channel_id: `telegram-${LONG}`, kind: 'message', status: `ok-${LONG}`, error: null, created_at: '2026-06-04T08:00:00Z' }]) });
154 | });
155 | await page.route('**/api/channel-connectors', async (route) => {
156 | callLog.push({ method: route.request().method(), url: route.request().url() });
157 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ kind: 'terminal', displayName: 'Terminal' }, { kind: 'telegram', displayName: 'Telegram' }, { kind: 'feishu', displayName: 'Feishu/Lark', authType: 'plugin' }]) });
158 | });
159 | await page.route('**/api/channel-connections', async (route) => {
160 | callLog.push({ method: route.request().method(), url: route.request().url() });
161 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ connection_id: `conn-${LONG}`, channel_id: `telegram-${LONG}`, kind: 'telegram', mode: 'polling', display_name: 'Telegram', account_id: `bot-${LONG}`, status: 'connected', auth_type: 'token' }]) });
162 | });
163 | await page.route('**/api/runtime/restart', async (route) => {
164 | callLog.push({ method: route.request().method(), url: route.request().url() });
165 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
166 | });
167 | }
168 |
169 | async function collectMetrics(page) {
170 | return await page.evaluate(() => {
171 | const viewportWidth = window.innerWidth;
172 | const bodyWidth = document.body.scrollWidth;
173 | const docWidth = document.documentElement.scrollWidth;
174 | const overflowElements = Array.from(document.querySelectorAll('body *')).map((el) => {
175 | const rect = el.getBoundingClientRect();
176 | return { tag: el.tagName, text: (el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 100), left: rect.left, right: rect.right, width: rect.width, className: String(el.className || '') };
177 | }).filter((item) => item.width > 1 && (item.left < -1 || item.right > viewportWidth + 1)).slice(0, 30);
178 | const smallTargets = Array.from(document.querySelectorAll('button, a, input:not([type=hidden]), textarea, [role="button"], [role="switch"], [role="combobox"], [role="tab"]')).map((el) => {
179 | const rect = el.getBoundingClientRect();
180 | const style = window.getComputedStyle(el);
181 | return { tag: el.tagName, text: (el.textContent || el.getAttribute('aria-label') || el.getAttribute('title') || '').trim().slice(0, 100), width: Math.round(rect.width), height: Math.round(rect.height), visible: style.visibility !== 'hidden' && style.display !== 'none' && Number(style.opacity) > 0.01 && rect.width > 0 && rect.height > 0 };
182 | }).filter((item) => item.visible && (item.width < 44 || item.height < 44)).slice(0, 30);
183 | return { viewportWidth, bodyWidth, docWidth, horizontalOverflow: Math.max(bodyWidth, docWidth) > viewportWidth + 1, overflowElements, smallTargets };
184 | });
185 | }
186 |
187 | test.describe('marketplace and settings QA', () => {
188 | test('marketplace search detail file install flow works', async ({ page }) => {
189 | await page.setViewportSize({ width: 390, height: 844 });
190 | await installRoutes(page);
191 | await page.goto(`${APP}/marketplace`);
192 | await expect(page.getByPlaceholder(/搜索技能|Search skills/)).toBeVisible();
193 | await page.getByPlaceholder(/搜索技能|Search skills/).fill('qa');
194 | await page.getByRole('button', { name: /搜索|Search/ }).click();
195 | await page.getByText(/SkillHub very-long-ui-token/).click();
196 | await expect(page.getByRole('heading', { name: /SkillHub very-long-ui-token/ })).toBeVisible();
197 | await page.getByRole('tab', { name: /文件|Files/ }).click();
198 | await page.getByRole('button', { name: /src\/very-long-ui-token/ }).click();
199 | await expect(page.getByText(/src\/very-long-ui-token/)).toBeVisible();
200 | await page.getByRole('button', { name: /安装|Install/ }).click();
201 | await expect.poll(() => callLog.some((call) => call.url.includes('/install') && call.method === 'POST')).toBe(true);
202 | await page.screenshot({ path: path.join(SHOT_DIR, 'market-detail-390x844.png'), fullPage: true });
203 | });
204 |
205 | test('settings agent provider channel and restart flows work', async ({ page }) => {
206 | await page.setViewportSize({ width: 390, height: 844 });
207 | await installRoutes(page);
208 | await page.goto(`${APP}/settings`);
209 | await expect(page.getByRole('heading', { name: /配置|Settings/ })).toBeVisible();
210 | await page.getByLabel(/温度|Temperature/).fill('0.3');
211 | await page.getByRole('button', { name: /保存智能体配置|Save agent config/ }).click();
212 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/agent/config') && call.method !== 'GET')).toBe(true);
213 | await page.getByRole('button', { name: /OpenAI/ }).click();
214 | await expect(page.getByRole('dialog')).toBeVisible();
215 | await page.getByLabel(/请求超时|Request timeout/).fill('60');
216 | await page.getByRole('button', { name: /保存|Save/ }).click();
217 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/providers/openai/config'))).toBe(true);
218 | await page.getByRole('button', { name: /Telegram/ }).click();
219 | await expect(page.getByRole('dialog')).toBeVisible();
220 | await page.getByLabel(/显示名|Display name/).fill(`Telegram ${LONG}`);
221 | await page.getByRole('button', { name: /保存通道配置|Save channel config/ }).click();
222 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/channels/') && call.method !== 'GET')).toBe(true);
223 | await page.screenshot({ path: path.join(SHOT_DIR, 'settings-channel-390x844.png'), fullPage: true });
224 | await page.keyboard.press('Escape');
225 | await page.getByRole('button', { name: /重启实例|Restart instance/ }).first().click();
226 | await expect(page.getByRole('dialog')).toBeVisible();
227 | await page.getByRole('button', { name: /^重启$|^Restart$/ }).click();
228 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/runtime/restart'))).toBe(true);
229 | });
230 |
231 | test('settings error state is readable', async ({ page }) => {
232 | await page.setViewportSize({ width: 390, height: 844 });
233 | await installRoutes(page, { statusError: true });
234 | await page.goto(`${APP}/settings`);
> 235 | await expect(page.getByText(/QA status error|无法连接|Unable to connect/)).toBeVisible();
| ^ Error: expect(locator).toBeVisible() failed
236 | await page.screenshot({ path: path.join(SHOT_DIR, 'settings-error-390x844.png'), fullPage: true });
237 | const metrics = await collectMetrics(page);
238 | expect(metrics.horizontalOverflow, JSON.stringify(metrics.overflowElements, null, 2)).toBe(false);
239 | });
240 |
241 | test('responsive layouts have no page overflow or visible small targets', async ({ browser }) => {
242 | const results = [];
243 | for (const viewport of [
244 | { width: 320, height: 568 },
245 | { width: 390, height: 844 },
246 | { width: 844, height: 390 },
247 | { width: 768, height: 1024 },
248 | { width: 1365, height: 900 },
249 | ]) {
250 | const market = await browser.newPage({ viewport });
251 | await installRoutes(market);
252 | await market.goto(`${APP}/marketplace`);
253 | await market.getByText(/SkillHub very-long-ui-token/).click();
254 | await market.screenshot({ path: path.join(SHOT_DIR, `market-${viewport.width}x${viewport.height}.png`), fullPage: true });
255 | const marketMetrics = await collectMetrics(market);
256 | await market.close();
257 |
258 | const settings = await browser.newPage({ viewport });
259 | await installRoutes(settings);
260 | await settings.goto(`${APP}/settings`);
261 | await settings.getByRole('button', { name: /Telegram/ }).click();
262 | await settings.screenshot({ path: path.join(SHOT_DIR, `settings-${viewport.width}x${viewport.height}.png`), fullPage: true });
263 | const settingsMetrics = await collectMetrics(settings);
264 | await settings.close();
265 |
266 | results.push({ viewport, marketMetrics, settingsMetrics });
267 | expect(marketMetrics.horizontalOverflow, JSON.stringify(marketMetrics.overflowElements, null, 2)).toBe(false);
268 | expect(settingsMetrics.horizontalOverflow, JSON.stringify(settingsMetrics.overflowElements, null, 2)).toBe(false);
269 | expect(marketMetrics.smallTargets, JSON.stringify(marketMetrics.smallTargets, null, 2)).toEqual([]);
270 | expect(settingsMetrics.smallTargets, JSON.stringify(settingsMetrics.smallTargets, null, 2)).toEqual([]);
271 | }
272 | fs.writeFileSync(REPORT, JSON.stringify({ results }, null, 2));
273 | });
274 | });
275 |
```

View File

@ -0,0 +1,316 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: market-settings-qa.spec.js >> marketplace and settings QA >> settings agent provider channel and restart flows work
- Location: ../../../../../../tmp/beaver-market-settings-qa/market-settings-qa.spec.js:205:3
# Error details
```
Error: expect(received).toBe(expected) // Object.is equality
Expected: true
Received: false
Call Log:
- Timeout 8000ms exceeded while waiting on the predicate
```
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- generic [ref=e2]:
- banner [ref=e3]:
- generic [ref=e5]:
- generic [ref=e6]:
- button "Open navigation" [ref=e7] [cursor=pointer]:
- img [ref=e8]
- link "Beaver" [ref=e9] [cursor=pointer]:
- /url: /
- generic [ref=e10]: Beaver
- generic [ref=e12]:
- generic [ref=e13]:
- img [ref=e14]
- button "ZH" [ref=e18] [cursor=pointer]
- button "EN" [ref=e19] [cursor=pointer]
- button "Open account menu" [ref=e20] [cursor=pointer]:
- generic [ref=e22]: U
- main [ref=e23]:
- generic [ref=e24]:
- generic [ref=e25]:
- generic [ref=e26]:
- heading "Settings" [level=1] [ref=e27]
- paragraph [ref=e28]: Manage models, tools, integrations, and instance runtime status. Tasks and notifications stay in their own pages.
- button "Refresh" [ref=e29] [cursor=pointer]:
- img [ref=e30]
- text: Refresh
- generic [ref=e35]:
- heading "Instance runtime" [level=3] [ref=e37]:
- img [ref=e38]
- text: Instance runtime
- generic [ref=e41]:
- generic [ref=e42]:
- generic [ref=e43]:
- paragraph [ref=e44]: Runtime and debugging
- paragraph [ref=e45]: Inspect per-chat runtime logs and current instance status.
- generic [ref=e46]:
- link "Runtime Logs" [ref=e47] [cursor=pointer]:
- /url: /logs
- img [ref=e48]
- text: Runtime Logs
- button "Restart instance" [ref=e51] [cursor=pointer]:
- img [ref=e52]
- text: Restart instance
- generic [ref=e57]:
- generic [ref=e58]:
- generic [ref=e59]: Config file
- code [ref=e61]: /home/ivan/very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789/config/beaver.yml
- generic [ref=e62]:
- generic [ref=e63]: Workspace
- code [ref=e65]: /home/ivan/very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789/workspace
- generic [ref=e66]:
- heading "Agent configuration" [level=3] [ref=e68]:
- img [ref=e69]
- text: Agent configuration
- generic [ref=e72]:
- generic [ref=e73]:
- generic [ref=e74]: Model
- code [ref=e76]: qwen-very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789
- generic [ref=e77]:
- generic [ref=e78]:
- generic [ref=e79]: Max tokens
- textbox "Max tokens" [ref=e80]:
- /placeholder: Model default
- text: "4096"
- generic [ref=e81]:
- generic [ref=e82]: Temperature
- textbox "Temperature" [ref=e83]: "0.2"
- generic [ref=e84]:
- generic [ref=e85]: Max tool iterations
- textbox "Max tool iterations" [ref=e86]: "30"
- button "Save agent config" [ref=e88] [cursor=pointer]
- generic [ref=e89]:
- heading "Providers" [level=3] [ref=e91]:
- img [ref=e92]
- text: Providers
- generic [ref=e97]:
- button "OpenAI very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789 Current default sk-********" [ref=e98] [cursor=pointer]:
- generic [ref=e99]:
- generic [ref=e100]:
- img [ref=e101]
- generic [ref=e104]: OpenAI very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789
- generic [ref=e105]: Current default
- generic [ref=e106]: sk-********
- img [ref=e107]
- button "DeepSeek Click to configure" [ref=e110] [cursor=pointer]:
- generic [ref=e111]:
- generic [ref=e112]:
- img [ref=e113]
- generic [ref=e117]: DeepSeek
- generic [ref=e118]: Click to configure
- img [ref=e119]
- generic [ref=e122]:
- heading "Channels" [level=3] [ref=e124]:
- img [ref=e125]
- text: Channels
- generic [ref=e133]:
- button "Weixin QR Connect" [ref=e134] [cursor=pointer]:
- generic [ref=e135]:
- generic [ref=e136]:
- img [ref=e137]
- generic [ref=e143]: Weixin
- generic [ref=e144]: QR
- generic [ref=e145]: Connect
- button "Feishu/Lark plugin Connect" [ref=e146] [cursor=pointer]:
- generic [ref=e147]:
- generic [ref=e148]:
- img [ref=e149]
- generic [ref=e155]: Feishu/Lark
- generic [ref=e156]: plugin
- generic [ref=e157]: Connect
- button "Terminal Local terminal connection Connection instructions Guide" [ref=e158] [cursor=pointer]:
- generic [ref=e159]:
- generic [ref=e160]:
- img [ref=e161]
- generic [ref=e167]: Terminal
- generic [ref=e168]: Local terminal connection
- generic [ref=e169]: Connection instructions
- generic [ref=e170]: Guide
- alert [ref=e171]
```
# Test source
```ts
112 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ detail: { version: '1.0.0-alpha-long-version-name-0123456789', status: 'published', parsedMetadataJson: JSON.stringify({ body: `# Skill\n\n${LONG_TEXT}` }) }, files: [{ filePath: 'SKILL.md', fileSize: 2048, contentType: 'text/markdown' }, { filePath: `src/${LONG}/implementation.ts`, fileSize: 4096, contentType: 'text/plain' }] }) });
113 | });
114 | await page.route('**/api/marketplaces/skills/*/*/versions', async (route) => {
115 | callLog.push({ method: route.request().method(), url: route.request().url() });
116 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [{ version: '1.0.0-alpha-long-version-name-0123456789', status: 'published', createdAt: '2026-06-04T08:00:00Z', changeReason: LONG_TEXT }], total: 1, page: 0, size: 20 }) });
117 | });
118 | await page.route('**/api/marketplaces/skills/*/*/install', async (route) => {
119 | callLog.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON?.() });
120 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, skill_name: `skill-${LONG}`, version: '1.0.0-alpha-long-version-name-0123456789', source: 'skillhub', namespace: `team-${LONG}` }) });
121 | });
122 | await page.route('**/api/marketplaces/skills/*/*', async (route) => {
123 | callLog.push({ method: route.request().method(), url: route.request().url() });
124 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(skillItem()) });
125 | });
126 | await page.route('**/api/status', async (route) => {
127 | callLog.push({ method: route.request().method(), url: route.request().url() });
128 | if (statusError) {
129 | await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ detail: 'QA status error' }) });
130 | return;
131 | }
132 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(statusPayload()) });
133 | });
134 | await page.route('**/api/providers/*/config', async (route) => {
135 | callLog.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON?.() });
136 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
137 | });
138 | await page.route('**/api/agent/config', async (route) => {
139 | callLog.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON?.() });
140 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
141 | });
142 | await page.route('**/api/channels/*/config', async (route) => {
143 | const body = route.request().postDataJSON?.();
144 | callLog.push({ method: route.request().method(), url: route.request().url(), body });
145 | if (route.request().method() === 'GET') {
146 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ channel_id: `telegram-${LONG}`, enabled: true, kind: 'telegram', mode: 'polling', account_id: `bot-${LONG}`, display_name: `Telegram ${LONG}`, config: { requireMentionInGroups: true }, secrets: { botToken: '***' } }) });
147 | } else {
148 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, channel_id: `telegram-${LONG}`, restart_required: true, channel: { channel_id: `telegram-${LONG}`, enabled: true, kind: 'telegram', mode: 'polling', account_id: `bot-${LONG}`, display_name: `Telegram ${LONG}`, config: body?.config || {}, secrets: {} } }) });
149 | }
150 | });
151 | await page.route('**/api/channels/*/events**', async (route) => {
152 | callLog.push({ method: route.request().method(), url: route.request().url() });
153 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ event_id: `event-${LONG}`, channel_id: `telegram-${LONG}`, kind: 'message', status: `ok-${LONG}`, error: null, created_at: '2026-06-04T08:00:00Z' }]) });
154 | });
155 | await page.route('**/api/channel-connectors', async (route) => {
156 | callLog.push({ method: route.request().method(), url: route.request().url() });
157 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ kind: 'terminal', displayName: 'Terminal' }, { kind: 'telegram', displayName: 'Telegram' }, { kind: 'feishu', displayName: 'Feishu/Lark', authType: 'plugin' }]) });
158 | });
159 | await page.route('**/api/channel-connections', async (route) => {
160 | callLog.push({ method: route.request().method(), url: route.request().url() });
161 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ connection_id: `conn-${LONG}`, channel_id: `telegram-${LONG}`, kind: 'telegram', mode: 'polling', display_name: 'Telegram', account_id: `bot-${LONG}`, status: 'connected', auth_type: 'token' }]) });
162 | });
163 | await page.route('**/api/runtime/restart', async (route) => {
164 | callLog.push({ method: route.request().method(), url: route.request().url() });
165 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
166 | });
167 | }
168 |
169 | async function collectMetrics(page) {
170 | return await page.evaluate(() => {
171 | const viewportWidth = window.innerWidth;
172 | const bodyWidth = document.body.scrollWidth;
173 | const docWidth = document.documentElement.scrollWidth;
174 | const overflowElements = Array.from(document.querySelectorAll('body *')).map((el) => {
175 | const rect = el.getBoundingClientRect();
176 | return { tag: el.tagName, text: (el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 100), left: rect.left, right: rect.right, width: rect.width, className: String(el.className || '') };
177 | }).filter((item) => item.width > 1 && (item.left < -1 || item.right > viewportWidth + 1)).slice(0, 30);
178 | const smallTargets = Array.from(document.querySelectorAll('button, a, input:not([type=hidden]), textarea, [role="button"], [role="switch"], [role="combobox"], [role="tab"]')).map((el) => {
179 | const rect = el.getBoundingClientRect();
180 | const style = window.getComputedStyle(el);
181 | return { tag: el.tagName, text: (el.textContent || el.getAttribute('aria-label') || el.getAttribute('title') || '').trim().slice(0, 100), width: Math.round(rect.width), height: Math.round(rect.height), visible: style.visibility !== 'hidden' && style.display !== 'none' && Number(style.opacity) > 0.01 && rect.width > 0 && rect.height > 0 };
182 | }).filter((item) => item.visible && (item.width < 44 || item.height < 44)).slice(0, 30);
183 | return { viewportWidth, bodyWidth, docWidth, horizontalOverflow: Math.max(bodyWidth, docWidth) > viewportWidth + 1, overflowElements, smallTargets };
184 | });
185 | }
186 |
187 | test.describe('marketplace and settings QA', () => {
188 | test('marketplace search detail file install flow works', async ({ page }) => {
189 | await page.setViewportSize({ width: 390, height: 844 });
190 | await installRoutes(page);
191 | await page.goto(`${APP}/marketplace`);
192 | await expect(page.getByPlaceholder(/搜索技能|Search skills/)).toBeVisible();
193 | await page.getByPlaceholder(/搜索技能|Search skills/).fill('qa');
194 | await page.getByRole('button', { name: /搜索|Search/ }).click();
195 | await page.getByText(/SkillHub very-long-ui-token/).click();
196 | await expect(page.getByRole('heading', { name: /SkillHub very-long-ui-token/ })).toBeVisible();
197 | await page.getByRole('tab', { name: /文件|Files/ }).click();
198 | await page.getByRole('button', { name: /src\/very-long-ui-token/ }).click();
199 | await expect(page.getByText(/src\/very-long-ui-token/)).toBeVisible();
200 | await page.getByRole('button', { name: /安装|Install/ }).click();
201 | await expect.poll(() => callLog.some((call) => call.url.includes('/install') && call.method === 'POST')).toBe(true);
202 | await page.screenshot({ path: path.join(SHOT_DIR, 'market-detail-390x844.png'), fullPage: true });
203 | });
204 |
205 | test('settings agent provider channel and restart flows work', async ({ page }) => {
206 | await page.setViewportSize({ width: 390, height: 844 });
207 | await installRoutes(page);
208 | await page.goto(`${APP}/settings`);
209 | await expect(page.getByRole('heading', { name: /配置|Settings/ })).toBeVisible();
210 | await page.getByLabel(/温度|Temperature/).fill('0.3');
211 | await page.getByRole('button', { name: /保存智能体配置|Save agent config/ }).click();
> 212 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/agent/config') && call.method !== 'GET')).toBe(true);
| ^ Error: expect(received).toBe(expected) // Object.is equality
213 | await page.getByRole('button', { name: /OpenAI/ }).click();
214 | await expect(page.getByRole('dialog')).toBeVisible();
215 | await page.getByLabel(/请求超时|Request timeout/).fill('60');
216 | await page.getByRole('button', { name: /保存|Save/ }).click();
217 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/providers/openai/config'))).toBe(true);
218 | await page.getByRole('button', { name: /Telegram/ }).click();
219 | await expect(page.getByRole('dialog')).toBeVisible();
220 | await page.getByLabel(/显示名|Display name/).fill(`Telegram ${LONG}`);
221 | await page.getByRole('button', { name: /保存通道配置|Save channel config/ }).click();
222 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/channels/') && call.method !== 'GET')).toBe(true);
223 | await page.screenshot({ path: path.join(SHOT_DIR, 'settings-channel-390x844.png'), fullPage: true });
224 | await page.keyboard.press('Escape');
225 | await page.getByRole('button', { name: /重启实例|Restart instance/ }).first().click();
226 | await expect(page.getByRole('dialog')).toBeVisible();
227 | await page.getByRole('button', { name: /^重启$|^Restart$/ }).click();
228 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/runtime/restart'))).toBe(true);
229 | });
230 |
231 | test('settings error state is readable', async ({ page }) => {
232 | await page.setViewportSize({ width: 390, height: 844 });
233 | await installRoutes(page, { statusError: true });
234 | await page.goto(`${APP}/settings`);
235 | await expect(page.getByText(/QA status error|无法连接|Unable to connect/)).toBeVisible();
236 | await page.screenshot({ path: path.join(SHOT_DIR, 'settings-error-390x844.png'), fullPage: true });
237 | const metrics = await collectMetrics(page);
238 | expect(metrics.horizontalOverflow, JSON.stringify(metrics.overflowElements, null, 2)).toBe(false);
239 | });
240 |
241 | test('responsive layouts have no page overflow or visible small targets', async ({ browser }) => {
242 | const results = [];
243 | for (const viewport of [
244 | { width: 320, height: 568 },
245 | { width: 390, height: 844 },
246 | { width: 844, height: 390 },
247 | { width: 768, height: 1024 },
248 | { width: 1365, height: 900 },
249 | ]) {
250 | const market = await browser.newPage({ viewport });
251 | await installRoutes(market);
252 | await market.goto(`${APP}/marketplace`);
253 | await market.getByText(/SkillHub very-long-ui-token/).click();
254 | await market.screenshot({ path: path.join(SHOT_DIR, `market-${viewport.width}x${viewport.height}.png`), fullPage: true });
255 | const marketMetrics = await collectMetrics(market);
256 | await market.close();
257 |
258 | const settings = await browser.newPage({ viewport });
259 | await installRoutes(settings);
260 | await settings.goto(`${APP}/settings`);
261 | await settings.getByRole('button', { name: /Telegram/ }).click();
262 | await settings.screenshot({ path: path.join(SHOT_DIR, `settings-${viewport.width}x${viewport.height}.png`), fullPage: true });
263 | const settingsMetrics = await collectMetrics(settings);
264 | await settings.close();
265 |
266 | results.push({ viewport, marketMetrics, settingsMetrics });
267 | expect(marketMetrics.horizontalOverflow, JSON.stringify(marketMetrics.overflowElements, null, 2)).toBe(false);
268 | expect(settingsMetrics.horizontalOverflow, JSON.stringify(settingsMetrics.overflowElements, null, 2)).toBe(false);
269 | expect(marketMetrics.smallTargets, JSON.stringify(marketMetrics.smallTargets, null, 2)).toEqual([]);
270 | expect(settingsMetrics.smallTargets, JSON.stringify(settingsMetrics.smallTargets, null, 2)).toEqual([]);
271 | }
272 | fs.writeFileSync(REPORT, JSON.stringify({ results }, null, 2));
273 | });
274 | });
275 |
```

View File

@ -0,0 +1,260 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: market-settings-qa.spec.js >> marketplace and settings QA >> responsive layouts have no page overflow or visible small targets
- Location: ../../../../../../tmp/beaver-market-settings-qa/market-settings-qa.spec.js:241:3
# Error details
```
TimeoutError: locator.click: Timeout 8000ms exceeded.
Call log:
- waiting for getByRole('button', { name: /Telegram/ })
```
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- generic [ref=e2]:
- banner [ref=e3]:
- generic [ref=e5]:
- button "Open navigation" [ref=e7] [cursor=pointer]:
- img [ref=e8]
- generic [ref=e10]:
- generic [ref=e11]:
- img [ref=e12]
- button "ZH" [ref=e16] [cursor=pointer]
- button "EN" [ref=e17] [cursor=pointer]
- button "Open account menu" [ref=e18] [cursor=pointer]:
- generic [ref=e20]: U
- main [ref=e21]:
- generic [ref=e22]:
- generic [ref=e23]:
- generic [ref=e24]:
- heading "Settings" [level=1] [ref=e25]
- paragraph [ref=e26]: Manage models, tools, integrations, and instance runtime status. Tasks and notifications stay in their own pages.
- button "Refresh" [ref=e27] [cursor=pointer]:
- img [ref=e28]
- text: Refresh
- generic [ref=e33]:
- heading "Instance runtime" [level=3] [ref=e35]:
- img [ref=e36]
- text: Instance runtime
- generic [ref=e39]:
- generic [ref=e40]:
- generic [ref=e41]:
- paragraph [ref=e42]: Runtime and debugging
- paragraph [ref=e43]: Inspect per-chat runtime logs and current instance status.
- generic [ref=e44]:
- link "Runtime Logs" [ref=e45] [cursor=pointer]:
- /url: /logs
- img [ref=e46]
- text: Runtime Logs
- button "Restart instance" [ref=e49] [cursor=pointer]:
- img [ref=e50]
- text: Restart instance
- generic [ref=e55]:
- generic [ref=e56]:
- generic [ref=e57]: Config file
- code [ref=e59]: /home/ivan/very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789/config/beaver.yml
- generic [ref=e60]:
- generic [ref=e61]: Workspace
- code [ref=e63]: /home/ivan/very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789/workspace
- generic [ref=e64]:
- heading "Agent configuration" [level=3] [ref=e66]:
- img [ref=e67]
- text: Agent configuration
- generic [ref=e70]:
- generic [ref=e71]:
- generic [ref=e72]: Model
- code [ref=e74]: qwen-very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789
- generic [ref=e75]:
- generic [ref=e76]:
- generic [ref=e77]: Max tokens
- textbox "Max tokens" [ref=e78]:
- /placeholder: Model default
- text: "4096"
- generic [ref=e79]:
- generic [ref=e80]: Temperature
- textbox "Temperature" [ref=e81]: "0.2"
- generic [ref=e82]:
- generic [ref=e83]: Max tool iterations
- textbox "Max tool iterations" [ref=e84]: "30"
- button "Save agent config" [ref=e86] [cursor=pointer]
- generic [ref=e87]:
- heading "Providers" [level=3] [ref=e89]:
- img [ref=e90]
- text: Providers
- generic [ref=e95]:
- button "OpenAI very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789 Current default sk-********" [ref=e96] [cursor=pointer]:
- generic [ref=e97]:
- generic [ref=e98]:
- img [ref=e99]
- generic [ref=e102]: OpenAI very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789
- generic [ref=e103]: Current default
- generic [ref=e104]: sk-********
- img [ref=e105]
- button "DeepSeek Click to configure" [ref=e108] [cursor=pointer]:
- generic [ref=e109]:
- generic [ref=e110]:
- img [ref=e111]
- generic [ref=e115]: DeepSeek
- generic [ref=e116]: Click to configure
- img [ref=e117]
- generic [ref=e120]:
- heading "Channels" [level=3] [ref=e122]:
- img [ref=e123]
- text: Channels
- generic [ref=e131]:
- button "Weixin QR Connect" [ref=e132] [cursor=pointer]:
- generic [ref=e133]:
- generic [ref=e134]:
- img [ref=e135]
- generic [ref=e141]: Weixin
- generic [ref=e142]: QR
- generic [ref=e143]: Connect
- button "Feishu/Lark plugin Connect" [ref=e144] [cursor=pointer]:
- generic [ref=e145]:
- generic [ref=e146]:
- img [ref=e147]
- generic [ref=e153]: Feishu/Lark
- generic [ref=e154]: plugin
- generic [ref=e155]: Connect
- button "Terminal Local terminal connection Connection instructions Guide" [ref=e156] [cursor=pointer]:
- generic [ref=e157]:
- generic [ref=e158]:
- img [ref=e159]
- generic [ref=e165]: Terminal
- generic [ref=e166]: Local terminal connection
- generic [ref=e167]: Connection instructions
- generic [ref=e168]: Guide
- alert [ref=e169]
```
# Test source
```ts
161 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ connection_id: `conn-${LONG}`, channel_id: `telegram-${LONG}`, kind: 'telegram', mode: 'polling', display_name: 'Telegram', account_id: `bot-${LONG}`, status: 'connected', auth_type: 'token' }]) });
162 | });
163 | await page.route('**/api/runtime/restart', async (route) => {
164 | callLog.push({ method: route.request().method(), url: route.request().url() });
165 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
166 | });
167 | }
168 |
169 | async function collectMetrics(page) {
170 | return await page.evaluate(() => {
171 | const viewportWidth = window.innerWidth;
172 | const bodyWidth = document.body.scrollWidth;
173 | const docWidth = document.documentElement.scrollWidth;
174 | const overflowElements = Array.from(document.querySelectorAll('body *')).map((el) => {
175 | const rect = el.getBoundingClientRect();
176 | return { tag: el.tagName, text: (el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 100), left: rect.left, right: rect.right, width: rect.width, className: String(el.className || '') };
177 | }).filter((item) => item.width > 1 && (item.left < -1 || item.right > viewportWidth + 1)).slice(0, 30);
178 | const smallTargets = Array.from(document.querySelectorAll('button, a, input:not([type=hidden]), textarea, [role="button"], [role="switch"], [role="combobox"], [role="tab"]')).map((el) => {
179 | const rect = el.getBoundingClientRect();
180 | const style = window.getComputedStyle(el);
181 | return { tag: el.tagName, text: (el.textContent || el.getAttribute('aria-label') || el.getAttribute('title') || '').trim().slice(0, 100), width: Math.round(rect.width), height: Math.round(rect.height), visible: style.visibility !== 'hidden' && style.display !== 'none' && Number(style.opacity) > 0.01 && rect.width > 0 && rect.height > 0 };
182 | }).filter((item) => item.visible && (item.width < 44 || item.height < 44)).slice(0, 30);
183 | return { viewportWidth, bodyWidth, docWidth, horizontalOverflow: Math.max(bodyWidth, docWidth) > viewportWidth + 1, overflowElements, smallTargets };
184 | });
185 | }
186 |
187 | test.describe('marketplace and settings QA', () => {
188 | test('marketplace search detail file install flow works', async ({ page }) => {
189 | await page.setViewportSize({ width: 390, height: 844 });
190 | await installRoutes(page);
191 | await page.goto(`${APP}/marketplace`);
192 | await expect(page.getByPlaceholder(/搜索技能|Search skills/)).toBeVisible();
193 | await page.getByPlaceholder(/搜索技能|Search skills/).fill('qa');
194 | await page.getByRole('button', { name: /搜索|Search/ }).click();
195 | await page.getByText(/SkillHub very-long-ui-token/).click();
196 | await expect(page.getByRole('heading', { name: /SkillHub very-long-ui-token/ })).toBeVisible();
197 | await page.getByRole('tab', { name: /文件|Files/ }).click();
198 | await page.getByRole('button', { name: /src\/very-long-ui-token/ }).click();
199 | await expect(page.getByText(/src\/very-long-ui-token/)).toBeVisible();
200 | await page.getByRole('button', { name: /安装|Install/ }).click();
201 | await expect.poll(() => callLog.some((call) => call.url.includes('/install') && call.method === 'POST')).toBe(true);
202 | await page.screenshot({ path: path.join(SHOT_DIR, 'market-detail-390x844.png'), fullPage: true });
203 | });
204 |
205 | test('settings agent provider channel and restart flows work', async ({ page }) => {
206 | await page.setViewportSize({ width: 390, height: 844 });
207 | await installRoutes(page);
208 | await page.goto(`${APP}/settings`);
209 | await expect(page.getByRole('heading', { name: /配置|Settings/ })).toBeVisible();
210 | await page.getByLabel(/温度|Temperature/).fill('0.3');
211 | await page.getByRole('button', { name: /保存智能体配置|Save agent config/ }).click();
212 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/agent/config') && call.method !== 'GET')).toBe(true);
213 | await page.getByRole('button', { name: /OpenAI/ }).click();
214 | await expect(page.getByRole('dialog')).toBeVisible();
215 | await page.getByLabel(/请求超时|Request timeout/).fill('60');
216 | await page.getByRole('button', { name: /保存|Save/ }).click();
217 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/providers/openai/config'))).toBe(true);
218 | await page.getByRole('button', { name: /Telegram/ }).click();
219 | await expect(page.getByRole('dialog')).toBeVisible();
220 | await page.getByLabel(/显示名|Display name/).fill(`Telegram ${LONG}`);
221 | await page.getByRole('button', { name: /保存通道配置|Save channel config/ }).click();
222 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/channels/') && call.method !== 'GET')).toBe(true);
223 | await page.screenshot({ path: path.join(SHOT_DIR, 'settings-channel-390x844.png'), fullPage: true });
224 | await page.keyboard.press('Escape');
225 | await page.getByRole('button', { name: /重启实例|Restart instance/ }).first().click();
226 | await expect(page.getByRole('dialog')).toBeVisible();
227 | await page.getByRole('button', { name: /^重启$|^Restart$/ }).click();
228 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/runtime/restart'))).toBe(true);
229 | });
230 |
231 | test('settings error state is readable', async ({ page }) => {
232 | await page.setViewportSize({ width: 390, height: 844 });
233 | await installRoutes(page, { statusError: true });
234 | await page.goto(`${APP}/settings`);
235 | await expect(page.getByText(/QA status error|无法连接|Unable to connect/)).toBeVisible();
236 | await page.screenshot({ path: path.join(SHOT_DIR, 'settings-error-390x844.png'), fullPage: true });
237 | const metrics = await collectMetrics(page);
238 | expect(metrics.horizontalOverflow, JSON.stringify(metrics.overflowElements, null, 2)).toBe(false);
239 | });
240 |
241 | test('responsive layouts have no page overflow or visible small targets', async ({ browser }) => {
242 | const results = [];
243 | for (const viewport of [
244 | { width: 320, height: 568 },
245 | { width: 390, height: 844 },
246 | { width: 844, height: 390 },
247 | { width: 768, height: 1024 },
248 | { width: 1365, height: 900 },
249 | ]) {
250 | const market = await browser.newPage({ viewport });
251 | await installRoutes(market);
252 | await market.goto(`${APP}/marketplace`);
253 | await market.getByText(/SkillHub very-long-ui-token/).click();
254 | await market.screenshot({ path: path.join(SHOT_DIR, `market-${viewport.width}x${viewport.height}.png`), fullPage: true });
255 | const marketMetrics = await collectMetrics(market);
256 | await market.close();
257 |
258 | const settings = await browser.newPage({ viewport });
259 | await installRoutes(settings);
260 | await settings.goto(`${APP}/settings`);
> 261 | await settings.getByRole('button', { name: /Telegram/ }).click();
| ^ TimeoutError: locator.click: Timeout 8000ms exceeded.
262 | await settings.screenshot({ path: path.join(SHOT_DIR, `settings-${viewport.width}x${viewport.height}.png`), fullPage: true });
263 | const settingsMetrics = await collectMetrics(settings);
264 | await settings.close();
265 |
266 | results.push({ viewport, marketMetrics, settingsMetrics });
267 | expect(marketMetrics.horizontalOverflow, JSON.stringify(marketMetrics.overflowElements, null, 2)).toBe(false);
268 | expect(settingsMetrics.horizontalOverflow, JSON.stringify(settingsMetrics.overflowElements, null, 2)).toBe(false);
269 | expect(marketMetrics.smallTargets, JSON.stringify(marketMetrics.smallTargets, null, 2)).toEqual([]);
270 | expect(settingsMetrics.smallTargets, JSON.stringify(settingsMetrics.smallTargets, null, 2)).toEqual([]);
271 | }
272 | fs.writeFileSync(REPORT, JSON.stringify({ results }, null, 2));
273 | });
274 | });
275 |
```