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:
@ -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 只做并集追加。
|
||||
|
||||
## 当前状态
|
||||
|
||||
这层已经支持:
|
||||
|
||||
@ -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]],
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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`
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 = {}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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}`}>
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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> {
|
||||
|
||||
60
app-instance/frontend/lib/channel-connector-state.test.ts
Normal file
60
app-instance/frontend/lib/channel-connector-state.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
28
app-instance/frontend/lib/channel-connector-state.ts
Normal file
28
app-instance/frontend/lib/channel-connector-state.ts
Normal 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;
|
||||
}
|
||||
@ -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({
|
||||
|
||||
@ -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 步',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
160
app-instance/frontend/lib/task-process.test.ts
Normal file
160
app-instance/frontend/lib/task-process.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
75
app-instance/frontend/lib/task-process.ts
Normal file
75
app-instance/frontend/lib/task-process.ts
Normal 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
|
||||
),
|
||||
};
|
||||
}
|
||||
52
app-instance/frontend/lib/task-timeline-view.test.ts
Normal file
52
app-instance/frontend/lib/task-timeline-view.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
37
app-instance/frontend/lib/task-timeline-view.ts
Normal file
37
app-instance/frontend/lib/task-timeline-view.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
9
app-instance/frontend/test-results/.last-run.json
Normal file
9
app-instance/frontend/test-results/.last-run.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"f5227d990c583bf1cb5e-1c04b5ceddc2ff622275",
|
||||
"f5227d990c583bf1cb5e-960e0b45f6e36c90c9ae",
|
||||
"f5227d990c583bf1cb5e-205bf022968074780a04",
|
||||
"f5227d990c583bf1cb5e-ebfd66136ede563d8670"
|
||||
]
|
||||
}
|
||||
@ -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 |
|
||||
```
|
||||
@ -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 |
|
||||
```
|
||||
@ -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 |
|
||||
```
|
||||
@ -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 |
|
||||
```
|
||||
Reference in New Issue
Block a user