Compare commits
3 Commits
f584c3e53b
...
b786b03a87
| Author | SHA1 | Date |
|---|---|---|
|
|
b786b03a87 | |
|
|
3a57e0f6ca | |
|
|
79fba9643c |
|
|
@ -10,7 +10,6 @@ from fastapi import APIRouter, HTTPException, Request
|
|||
from fastapi.responses import FileResponse, PlainTextResponse, Response
|
||||
|
||||
from app.gateway.path_utils import resolve_thread_virtual_path
|
||||
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -63,38 +62,6 @@ def _find_compat_filename_match(missing_path: Path) -> Path | None:
|
|||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def _list_reference_files_in_dir(
|
||||
thread_id: str,
|
||||
root_dir: Path,
|
||||
virtual_prefix: str,
|
||||
source: str,
|
||||
) -> list[dict[str, str]]:
|
||||
if not root_dir.is_dir():
|
||||
return []
|
||||
|
||||
files: list[dict[str, str]] = []
|
||||
for file_path in sorted(root_dir.rglob("*")):
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
relative_path = file_path.relative_to(root_dir).as_posix()
|
||||
# Internal uploaded skills are bootstrap assets, not user-facing references.
|
||||
if source == "upload" and relative_path.startswith("skill/"):
|
||||
continue
|
||||
virtual_path = f"{virtual_prefix}/{relative_path}"
|
||||
encoded_virtual_path = quote(virtual_path, safe="/")
|
||||
files.append(
|
||||
{
|
||||
"filename": file_path.name,
|
||||
"size": str(file_path.stat().st_size),
|
||||
"virtual_path": virtual_path,
|
||||
"artifact_url": f"/api/threads/{thread_id}/artifacts{encoded_virtual_path}",
|
||||
"source": source,
|
||||
}
|
||||
)
|
||||
|
||||
return files
|
||||
|
||||
|
||||
def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool:
|
||||
"""Check if file is text by examining content for null bytes."""
|
||||
try:
|
||||
|
|
@ -139,38 +106,6 @@ def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> byte
|
|||
return None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/threads/{thread_id}/artifacts/list",
|
||||
summary="List Reference Files",
|
||||
description="List current files under outputs and uploads for @ references.",
|
||||
)
|
||||
async def list_reference_files(thread_id: str) -> dict:
|
||||
"""List real files from outputs/uploads so mention candidates stay fresh."""
|
||||
paths = get_paths()
|
||||
outputs_dir = paths.sandbox_outputs_dir(thread_id)
|
||||
uploads_dir = paths.sandbox_uploads_dir(thread_id)
|
||||
|
||||
outputs_virtual_prefix = f"{VIRTUAL_PATH_PREFIX}/outputs"
|
||||
uploads_virtual_prefix = f"{VIRTUAL_PATH_PREFIX}/uploads"
|
||||
output_files = _list_reference_files_in_dir(
|
||||
thread_id,
|
||||
outputs_dir,
|
||||
outputs_virtual_prefix,
|
||||
"artifact",
|
||||
)
|
||||
upload_files = _list_reference_files_in_dir(
|
||||
thread_id,
|
||||
uploads_dir,
|
||||
uploads_virtual_prefix,
|
||||
"upload",
|
||||
)
|
||||
files = [*output_files, *upload_files]
|
||||
return {
|
||||
"files": files,
|
||||
"count": len(files),
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/threads/{thread_id}/artifacts/{path:path}",
|
||||
summary="Get Artifact File",
|
||||
|
|
|
|||
|
|
@ -67,13 +67,11 @@ async def proxy_request(provider: str, path: str, request: Request) -> Response:
|
|||
path=path,
|
||||
request=request,
|
||||
body=body,
|
||||
request_json=request_json,
|
||||
thread_id=thread_id,
|
||||
idempotency_key=idempotency_key,
|
||||
task_id_jsonpath=submit_route.task_id_jsonpath,
|
||||
route_frozen_amount=submit_route.frozen_amount,
|
||||
route_frozen_type=submit_route.frozen_type,
|
||||
route_frozen_token=submit_route.frozen_token,
|
||||
)
|
||||
|
||||
if query_route:
|
||||
|
|
@ -111,13 +109,11 @@ async def _handle_submit(
|
|||
path: str,
|
||||
request: Request,
|
||||
body: bytes,
|
||||
request_json: dict[str, Any] | None,
|
||||
thread_id: str | None,
|
||||
idempotency_key: str | None,
|
||||
task_id_jsonpath: str,
|
||||
route_frozen_amount: float | None,
|
||||
route_frozen_type: int | None,
|
||||
route_frozen_token: int | None,
|
||||
) -> Response:
|
||||
ledger = get_ledger()
|
||||
|
||||
|
|
@ -133,7 +129,6 @@ async def _handle_submit(
|
|||
# Reserve billing before touching the provider
|
||||
reserve_frozen_amount = route_frozen_amount if route_frozen_amount is not None else provider_config.frozen_amount
|
||||
reserve_frozen_type = route_frozen_type if route_frozen_type is not None else provider_config.frozen_type
|
||||
reserve_frozen_token = route_frozen_token if route_frozen_token is not None else provider_config.frozen_token
|
||||
frozen_id = await billing.reserve(
|
||||
thread_id=thread_id,
|
||||
call_id=record.call_id,
|
||||
|
|
@ -141,11 +136,9 @@ async def _handle_submit(
|
|||
operation=path,
|
||||
frozen_amount=reserve_frozen_amount,
|
||||
frozen_type=reserve_frozen_type,
|
||||
frozen_token=reserve_frozen_token,
|
||||
request_payload=request_json,
|
||||
)
|
||||
if frozen_id:
|
||||
ledger.set_reserved(record.proxy_call_id, frozen_id, reserve_frozen_type)
|
||||
ledger.set_reserved(record.proxy_call_id, frozen_id)
|
||||
|
||||
# Forward to provider
|
||||
try:
|
||||
|
|
@ -163,32 +156,6 @@ async def _handle_submit(
|
|||
|
||||
resp_json = _try_parse_json(resp_body)
|
||||
|
||||
if resp_json is None:
|
||||
if frozen_id and reserve_frozen_type == 1:
|
||||
usage_input_tokens, usage_output_tokens = _extract_usage_tokens_from_submit_stream(resp_body)
|
||||
logger.debug(
|
||||
"[ThirdPartyProxy] submit stream usage resolved: proxy_call_id=%s usage_input_tokens=%s usage_output_tokens=%s",
|
||||
record.proxy_call_id,
|
||||
usage_input_tokens,
|
||||
usage_output_tokens,
|
||||
)
|
||||
|
||||
if ledger.try_claim_finalize(record.proxy_call_id):
|
||||
ok = await billing.finalize(
|
||||
frozen_id=frozen_id,
|
||||
final_amount=0.0,
|
||||
finalize_reason="success",
|
||||
usage_input_tokens=usage_input_tokens,
|
||||
usage_output_tokens=usage_output_tokens,
|
||||
)
|
||||
if ok:
|
||||
ledger.set_finalized(record.proxy_call_id, "SUCCESS")
|
||||
else:
|
||||
ledger.set_finalize_failed(record.proxy_call_id, "FAILED")
|
||||
|
||||
media_type = resp_headers.get("content-type")
|
||||
return Response(content=resp_body, status_code=status_code, headers=resp_headers, media_type=media_type)
|
||||
|
||||
# HTTP-level failure
|
||||
if status_code >= 400:
|
||||
reason = f"error_http_{status_code}"
|
||||
|
|
@ -305,31 +272,18 @@ async def _handle_query(
|
|||
"[ThirdPartyProxy] finalize claimed: proxy_call_id=%s",
|
||||
record.proxy_call_id,
|
||||
)
|
||||
resolved_frozen_type = (
|
||||
record.frozen_type if record.frozen_type is not None else provider_config.frozen_type
|
||||
)
|
||||
|
||||
usage_input_tokens = 0
|
||||
usage_output_tokens = 0
|
||||
usage_paths = list(query_route.usage_jsonpaths or [])
|
||||
if not usage_paths and query_route.usage_jsonpath:
|
||||
usage_paths = [query_route.usage_jsonpath]
|
||||
|
||||
final_amount: float = 0.0
|
||||
if is_success:
|
||||
if resolved_frozen_type == 1:
|
||||
usage_input_tokens, usage_output_tokens = _extract_usage_tokens(resp_json)
|
||||
else:
|
||||
final_amount = _resolve_final_amount(resp_json, query_route)
|
||||
if is_success and query_route.usage_jsonpath:
|
||||
raw_amount = proxy.jsonpath_get(resp_json, query_route.usage_jsonpath)
|
||||
try:
|
||||
final_amount = float(raw_amount) if raw_amount is not None else 0.0
|
||||
except (TypeError, ValueError):
|
||||
final_amount = 0.0
|
||||
|
||||
logger.debug(
|
||||
"[ThirdPartyProxy] finalize amount resolved: proxy_call_id=%s frozen_type=%s final_amount=%s usage_input_tokens=%s usage_output_tokens=%s usage_paths=%s legacy_path=%s",
|
||||
"[ThirdPartyProxy] finalize amount resolved: proxy_call_id=%s final_amount=%s usage_path=%s",
|
||||
record.proxy_call_id,
|
||||
resolved_frozen_type,
|
||||
final_amount,
|
||||
usage_input_tokens,
|
||||
usage_output_tokens,
|
||||
usage_paths,
|
||||
query_route.usage_jsonpath,
|
||||
)
|
||||
|
||||
|
|
@ -349,8 +303,6 @@ async def _handle_query(
|
|||
frozen_id=record.frozen_id,
|
||||
final_amount=final_amount,
|
||||
finalize_reason=finalize_reason,
|
||||
usage_input_tokens=usage_input_tokens,
|
||||
usage_output_tokens=usage_output_tokens,
|
||||
)
|
||||
logger.info(
|
||||
"[ThirdPartyProxy] finalize result: proxy_call_id=%s ok=%s",
|
||||
|
|
@ -439,85 +391,6 @@ def _try_parse_json(data: bytes) -> dict[str, Any] | None:
|
|||
return None
|
||||
|
||||
|
||||
def _resolve_final_amount(resp_json: dict[str, Any], query_route) -> float:
|
||||
"""Resolve final billing amount from configured usage paths.
|
||||
|
||||
Priority:
|
||||
1) `usage_jsonpaths` (sum all valid numeric values)
|
||||
2) legacy `usage_jsonpath` (single value)
|
||||
"""
|
||||
usage_paths = list(query_route.usage_jsonpaths or [])
|
||||
if not usage_paths and query_route.usage_jsonpath:
|
||||
usage_paths = [query_route.usage_jsonpath]
|
||||
|
||||
total = 0.0
|
||||
for path in usage_paths:
|
||||
raw = proxy.jsonpath_get(resp_json, path)
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
total += float(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
return total
|
||||
|
||||
|
||||
def _extract_usage_tokens(resp_json: dict[str, Any]) -> tuple[int, int]:
|
||||
usage = resp_json.get("usage")
|
||||
if not isinstance(usage, dict):
|
||||
return 0, 0
|
||||
|
||||
input_tokens = _as_int(usage.get("input_tokens"))
|
||||
if input_tokens == 0:
|
||||
input_tokens = _as_int(usage.get("prompt_tokens"))
|
||||
|
||||
output_tokens = _as_int(usage.get("output_tokens"))
|
||||
if output_tokens == 0:
|
||||
output_tokens = _as_int(usage.get("completion_tokens"))
|
||||
|
||||
return input_tokens, output_tokens
|
||||
|
||||
|
||||
def _extract_usage_tokens_from_submit_stream(resp_body: bytes) -> tuple[int, int]:
|
||||
"""Extract usage tokens from the final SSE chunk in a submit stream response."""
|
||||
if not resp_body:
|
||||
return 0, 0
|
||||
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
for raw_line in resp_body.splitlines():
|
||||
line = raw_line.decode("utf-8", errors="replace").strip()
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
payload_str = line[5:].strip()
|
||||
if not payload_str or payload_str == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(payload_str)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
if isinstance(payload, dict):
|
||||
in_tokens, out_tokens = _extract_usage_tokens(payload)
|
||||
if in_tokens or out_tokens:
|
||||
input_tokens, output_tokens = in_tokens, out_tokens
|
||||
|
||||
return input_tokens, output_tokens
|
||||
|
||||
|
||||
def _as_int(value: Any) -> int:
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return int(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return int(float(value))
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def _proxy_response(
|
||||
data: dict[str, Any],
|
||||
proxy_call_id: str | None,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
|
@ -29,8 +28,6 @@ async def reserve(
|
|||
operation: str,
|
||||
frozen_amount: float,
|
||||
frozen_type: int | None,
|
||||
frozen_token: int = 0,
|
||||
request_payload: dict[str, Any] | None = None,
|
||||
) -> str | None:
|
||||
"""Reserve billing before forwarding a submit call.
|
||||
|
||||
|
|
@ -47,25 +44,19 @@ async def reserve(
|
|||
)
|
||||
return None
|
||||
|
||||
resolved_frozen_type = frozen_type if frozen_type is not None else cfg.frozen_type
|
||||
expire_at = datetime.now() + timedelta(seconds=cfg.default_expire_seconds)
|
||||
payload: dict[str, Any] = {
|
||||
payload = {
|
||||
"sessionId": thread_id,
|
||||
"callId": call_id,
|
||||
"modelName": _extract_model_name(request_payload) or provider,
|
||||
"modelName": provider,
|
||||
"question": f"skill invokes {operation.split('/')[-1]}",
|
||||
"frozenType": resolved_frozen_type,
|
||||
"frozenAmount": frozen_amount,
|
||||
"frozenType": frozen_type if frozen_type is not None else cfg.frozen_type,
|
||||
"estimatedInputTokens": 0,
|
||||
"estimatedOutputTokens": 0,
|
||||
"expireAt": expire_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
|
||||
if resolved_frozen_type == 1:
|
||||
payload["estimatedInputTokens"] = int(frozen_token)
|
||||
payload["estimatedOutputTokens"] = int(frozen_token)
|
||||
else:
|
||||
payload["frozenAmount"] = frozen_amount
|
||||
payload["estimatedInputTokens"] = 0
|
||||
payload["estimatedOutputTokens"] = 0
|
||||
|
||||
logger.info(
|
||||
"[ThirdPartyProxy][Billing] reserve request: url=%s call_id=%s provider=%s thread_id=%s",
|
||||
cfg.reserve_url,
|
||||
|
|
@ -123,8 +114,6 @@ async def finalize(
|
|||
frozen_id: str,
|
||||
final_amount: float,
|
||||
finalize_reason: str,
|
||||
usage_input_tokens: int = 0,
|
||||
usage_output_tokens: int = 0,
|
||||
) -> bool:
|
||||
"""Finalize billing after a third-party call reaches a terminal state.
|
||||
|
||||
|
|
@ -146,9 +135,9 @@ async def finalize(
|
|||
payload = {
|
||||
"frozenId": frozen_id,
|
||||
"finalAmount": final_amount,
|
||||
"usageInputTokens": usage_input_tokens,
|
||||
"usageOutputTokens": usage_output_tokens,
|
||||
"usageTotalTokens": usage_input_tokens + usage_output_tokens,
|
||||
"usageInputTokens": 0,
|
||||
"usageOutputTokens": 0,
|
||||
"usageTotalTokens": 0,
|
||||
"finalizeReason": finalize_reason,
|
||||
}
|
||||
|
||||
|
|
@ -199,12 +188,3 @@ def _is_success(data: dict) -> bool:
|
|||
if isinstance(status, int) and status in _SUCCESS_STATUS_CODES:
|
||||
return True
|
||||
return data.get("success") is True
|
||||
|
||||
|
||||
def _extract_model_name(request_payload: dict[str, Any] | None) -> str | None:
|
||||
if not isinstance(request_payload, dict):
|
||||
return None
|
||||
model = request_payload.get("model")
|
||||
if isinstance(model, str) and model:
|
||||
return model
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ class CallRecord:
|
|||
# call_id is sent to the billing platform (callId in reserve payload)
|
||||
call_id: str
|
||||
frozen_id: str | None = None
|
||||
frozen_type: int | None = None
|
||||
provider_task_id: str | None = None
|
||||
billing_state: BillingState = "UNRESERVED"
|
||||
task_state: TaskState = "PENDING"
|
||||
|
|
@ -110,18 +109,16 @@ class CallLedger:
|
|||
def get_by_idempotency_key(self, provider: str, idempotency_key: str) -> CallRecord | None:
|
||||
return self._get_by_idem_key_locked(provider, idempotency_key)
|
||||
|
||||
def set_reserved(self, proxy_call_id: str, frozen_id: str, frozen_type: int | None = None) -> None:
|
||||
def set_reserved(self, proxy_call_id: str, frozen_id: str) -> None:
|
||||
with self._lock:
|
||||
record = self._records.get(proxy_call_id)
|
||||
if record:
|
||||
record.frozen_id = frozen_id
|
||||
record.frozen_type = frozen_type
|
||||
record.billing_state = "RESERVED"
|
||||
logger.info(
|
||||
"[ThirdPartyProxy][Ledger] reserved: proxy_call_id=%s frozen_id=%s frozen_type=%s",
|
||||
"[ThirdPartyProxy][Ledger] reserved: proxy_call_id=%s frozen_id=%s",
|
||||
proxy_call_id,
|
||||
frozen_id,
|
||||
frozen_type,
|
||||
)
|
||||
# logger.debug(
|
||||
# "[ThirdPartyProxy][Ledger] reserve state: call_id=%s provider=%s task_state=%s",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
|
@ -18,7 +17,16 @@ from deerflow.config.third_party_proxy_config import (
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_KEY_MARKER = "__API_KEY_MARKER__"
|
||||
_SENSITIVE_HEADERS = frozenset(
|
||||
[
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
"x-api-key",
|
||||
"api-key",
|
||||
"cookie",
|
||||
"set-cookie",
|
||||
]
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider config lookup
|
||||
|
|
@ -146,6 +154,17 @@ _STRIP_RESPONSE_HEADERS = frozenset(
|
|||
)
|
||||
|
||||
|
||||
def _sanitize_headers(headers: dict[str, str]) -> dict[str, str]:
|
||||
"""Return a copy of headers with sensitive values redacted."""
|
||||
sanitized: dict[str, str] = {}
|
||||
for key, value in headers.items():
|
||||
if key.lower() in _SENSITIVE_HEADERS:
|
||||
sanitized[key] = "***"
|
||||
else:
|
||||
sanitized[key] = value
|
||||
return sanitized
|
||||
|
||||
|
||||
def _preview_body(data: bytes, limit: int = 2048) -> str:
|
||||
"""Return a safe textual preview of body bytes for debugging logs."""
|
||||
if not data:
|
||||
|
|
@ -157,53 +176,6 @@ def _preview_body(data: bytes, limit: int = 2048) -> str:
|
|||
return text
|
||||
|
||||
|
||||
def _replace_api_key_marker_in_headers(headers: dict[str, str], api_key: str) -> dict[str, str]:
|
||||
"""Replace API key marker placeholders in header values."""
|
||||
replaced: dict[str, str] = {}
|
||||
for key, value in headers.items():
|
||||
if isinstance(value, str) and API_KEY_MARKER in value:
|
||||
replaced[key] = value.replace(API_KEY_MARKER, api_key)
|
||||
else:
|
||||
replaced[key] = value
|
||||
return replaced
|
||||
|
||||
|
||||
def _header_value(headers: dict[str, str], key: str) -> str | None:
|
||||
target = key.lower()
|
||||
for h_key, h_val in headers.items():
|
||||
if h_key.lower() == target:
|
||||
return h_val
|
||||
return None
|
||||
|
||||
|
||||
def _replace_api_key_marker_in_json(data: Any, api_key: str) -> Any:
|
||||
if isinstance(data, str):
|
||||
return data.replace(API_KEY_MARKER, api_key)
|
||||
if isinstance(data, list):
|
||||
return [_replace_api_key_marker_in_json(item, api_key) for item in data]
|
||||
if isinstance(data, dict):
|
||||
return {k: _replace_api_key_marker_in_json(v, api_key) for k, v in data.items()}
|
||||
return data
|
||||
|
||||
|
||||
def _replace_api_key_marker_in_body(headers: dict[str, str], body: bytes, api_key: str) -> bytes:
|
||||
"""Replace API key marker in JSON body payloads only."""
|
||||
if not body:
|
||||
return body
|
||||
|
||||
content_type = _header_value(headers, "content-type") or ""
|
||||
if "application/json" not in content_type.lower():
|
||||
return body
|
||||
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return body
|
||||
|
||||
replaced = _replace_api_key_marker_in_json(parsed, api_key)
|
||||
return json.dumps(replaced, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
async def forward_request(
|
||||
*,
|
||||
provider_config: ThirdPartyProviderConfig,
|
||||
|
|
@ -230,9 +202,6 @@ async def forward_request(
|
|||
if provider_config.api_key_env:
|
||||
api_key = os.getenv(provider_config.api_key_env)
|
||||
if api_key:
|
||||
# Dependency-injection style: replace marker placeholders first.
|
||||
forward_headers = _replace_api_key_marker_in_headers(forward_headers, api_key)
|
||||
body = _replace_api_key_marker_in_body(forward_headers, body, api_key)
|
||||
forward_headers[provider_config.api_key_header] = provider_config.api_key_prefix + api_key
|
||||
else:
|
||||
logger.warning(
|
||||
|
|
@ -243,7 +212,7 @@ async def forward_request(
|
|||
logger.info("[ThirdPartyProxy] → %s %s", method, target_url)
|
||||
logger.debug(
|
||||
"[ThirdPartyProxy] request headers=%s",
|
||||
forward_headers,
|
||||
_sanitize_headers(forward_headers)
|
||||
)
|
||||
logger.debug(
|
||||
"[ThirdPartyProxy] request body(%dB)=%s",
|
||||
|
|
@ -267,7 +236,7 @@ async def forward_request(
|
|||
logger.info("[ThirdPartyProxy] ← %s %s %d", method, target_url, response.status_code)
|
||||
logger.debug(
|
||||
"[ThirdPartyProxy] response headers=%s",
|
||||
response_headers,
|
||||
_sanitize_headers(response_headers)
|
||||
)
|
||||
logger.debug(
|
||||
"[ThirdPartyProxy] response body(%dB)=%s",
|
||||
|
|
|
|||
|
|
@ -2,12 +2,10 @@ import logging
|
|||
|
||||
from langchain.agents import create_agent
|
||||
from langchain.agents.middleware import AgentMiddleware, SummarizationMiddleware
|
||||
from langchain_core.messages.human import HumanMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from deerflow.agents.lead_agent.prompt import apply_prompt_template
|
||||
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
||||
from deerflow.agents.middlewares.artifact_reconcile_middleware import ArtifactReconcileMiddleware
|
||||
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
||||
from deerflow.agents.middlewares.message_timestamp_middleware import MessageTimestampMiddleware
|
||||
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
||||
|
|
@ -25,15 +23,6 @@ from deerflow.models import create_chat_model
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUMMARY_MESSAGE_TITLE = "以下是目前对话的摘要:"
|
||||
|
||||
|
||||
class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
||||
"""Summarization middleware with DeerFlow's user-facing summary heading."""
|
||||
|
||||
def _build_new_messages(self, summary: str) -> list[HumanMessage]:
|
||||
return [HumanMessage(content=f"{SUMMARY_MESSAGE_TITLE}\n\n{summary}")]
|
||||
|
||||
|
||||
def _resolve_model_name(requested_model_name: str | None = None) -> str:
|
||||
"""Resolve a runtime model name safely, falling back to default if invalid. Returns None if no models are configured."""
|
||||
|
|
@ -89,7 +78,7 @@ def _create_summarization_middleware() -> SummarizationMiddleware | None:
|
|||
if config.summary_prompt is not None:
|
||||
kwargs["summary_prompt"] = config.summary_prompt
|
||||
|
||||
return DeerFlowSummarizationMiddleware(**kwargs)
|
||||
return SummarizationMiddleware(**kwargs)
|
||||
|
||||
|
||||
def _create_todo_list_middleware(is_plan_mode: bool) -> TodoMiddleware | None:
|
||||
|
|
@ -245,9 +234,6 @@ def _build_middlewares(config: RunnableConfig, model_name: str | None, agent_nam
|
|||
if get_app_config().token_usage.enabled:
|
||||
middlewares.append(TokenUsageMiddleware())
|
||||
|
||||
# Reconcile stale artifact entries against real outputs files.
|
||||
middlewares.append(ArtifactReconcileMiddleware())
|
||||
|
||||
# Stamp every conversation message with backend timestamp metadata.
|
||||
middlewares.append(MessageTimestampMiddleware())
|
||||
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
import logging
|
||||
from pathlib import Path
|
||||
from typing import NotRequired, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from deerflow.agents.thread_state import (
|
||||
ARTIFACTS_REPLACE_SENTINEL,
|
||||
ThreadDataState,
|
||||
)
|
||||
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_OUTPUTS_VIRTUAL_PREFIX = f"{VIRTUAL_PATH_PREFIX}/outputs/"
|
||||
_OUTPUTS_VIRTUAL_PREFIX_NO_LEADING_SLASH = _OUTPUTS_VIRTUAL_PREFIX.lstrip("/")
|
||||
|
||||
|
||||
class ArtifactReconcileState(AgentState):
|
||||
"""Compatible with the `ThreadState` schema."""
|
||||
|
||||
artifacts: NotRequired[list[str] | None]
|
||||
thread_data: NotRequired[ThreadDataState | None]
|
||||
|
||||
|
||||
class ArtifactReconcileMiddleware(AgentMiddleware[ArtifactReconcileState]):
|
||||
"""Keep artifact state aligned with files currently in outputs."""
|
||||
|
||||
state_schema = ArtifactReconcileState
|
||||
|
||||
def _to_outputs_file(self, virtual_path: str, outputs_dir: Path) -> Path | None:
|
||||
stripped = virtual_path.lstrip("/")
|
||||
if not stripped.startswith(_OUTPUTS_VIRTUAL_PREFIX_NO_LEADING_SLASH):
|
||||
# Keep non-outputs paths untouched; this middleware is for outputs drift.
|
||||
return None
|
||||
|
||||
relative = stripped[len(_OUTPUTS_VIRTUAL_PREFIX_NO_LEADING_SLASH) :]
|
||||
if not relative:
|
||||
return None
|
||||
|
||||
candidate = (outputs_dir / relative).resolve()
|
||||
try:
|
||||
candidate.relative_to(outputs_dir)
|
||||
except ValueError:
|
||||
return None
|
||||
return candidate
|
||||
|
||||
def _to_virtual_artifact(self, actual_path: Path, outputs_dir: Path) -> str | None:
|
||||
try:
|
||||
relative = actual_path.resolve().relative_to(outputs_dir)
|
||||
except ValueError:
|
||||
return None
|
||||
return f"{_OUTPUTS_VIRTUAL_PREFIX}{relative.as_posix()}"
|
||||
|
||||
def _discover_outputs(self, outputs_dir: Path) -> list[str]:
|
||||
if not outputs_dir.is_dir():
|
||||
return []
|
||||
|
||||
discovered: list[str] = []
|
||||
for path in sorted(outputs_dir.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
virtual_path = self._to_virtual_artifact(path, outputs_dir)
|
||||
if virtual_path:
|
||||
discovered.append(virtual_path)
|
||||
return discovered
|
||||
|
||||
@override
|
||||
def before_model(
|
||||
self,
|
||||
state: ArtifactReconcileState,
|
||||
runtime: Runtime, # noqa: ARG002
|
||||
) -> dict | None:
|
||||
artifacts = state.get("artifacts") or []
|
||||
thread_data = state.get("thread_data") or {}
|
||||
outputs_path = thread_data.get("outputs_path")
|
||||
if not outputs_path:
|
||||
return None
|
||||
|
||||
outputs_dir = Path(outputs_path).resolve()
|
||||
kept: list[str] = []
|
||||
changed = False
|
||||
|
||||
for artifact in artifacts:
|
||||
if not isinstance(artifact, str):
|
||||
changed = True
|
||||
continue
|
||||
if artifact == ARTIFACTS_REPLACE_SENTINEL:
|
||||
changed = True
|
||||
continue
|
||||
|
||||
actual_path = self._to_outputs_file(artifact, outputs_dir)
|
||||
if actual_path is None:
|
||||
kept.append(artifact)
|
||||
continue
|
||||
|
||||
if actual_path.exists() and actual_path.is_file():
|
||||
kept.append(artifact)
|
||||
else:
|
||||
changed = True
|
||||
logger.info(
|
||||
"Reconciled stale artifact from state: virtual=%s outputs_dir=%s",
|
||||
artifact,
|
||||
outputs_dir,
|
||||
)
|
||||
|
||||
discovered = self._discover_outputs(outputs_dir)
|
||||
merged = list(dict.fromkeys([*kept, *discovered]))
|
||||
if merged != kept:
|
||||
changed = True
|
||||
|
||||
if not changed:
|
||||
return None
|
||||
|
||||
return {"artifacts": [ARTIFACTS_REPLACE_SENTINEL, *merged]}
|
||||
|
|
@ -174,7 +174,6 @@ def _extract_run_id(request: ModelRequest) -> str | None: # noqa: ARG001
|
|||
|
||||
def _reserve_failure_message(status_code: int | None) -> str:
|
||||
if status_code in _blocking_reserve_code_set():
|
||||
# TODO: 将账单错误文案迁移到国际化资源中,按语言返回提示。
|
||||
return "The account balance is insufficient for this model call."
|
||||
return "Billing reservation failed. Please try again later."
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"""Middleware for intercepting clarification requests and presenting them to the user."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import override
|
||||
|
|
@ -36,28 +35,6 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
|||
|
||||
state_schema = ClarificationMiddlewareState
|
||||
|
||||
def _normalize_options(self, options: object) -> list[str]:
|
||||
"""Normalize clarification options into a list of display strings."""
|
||||
if options is None:
|
||||
return []
|
||||
|
||||
if isinstance(options, list):
|
||||
return [str(option) for option in options]
|
||||
|
||||
if isinstance(options, str):
|
||||
stripped = options.strip()
|
||||
if not stripped:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
return [stripped]
|
||||
if isinstance(parsed, list):
|
||||
return [str(option) for option in parsed]
|
||||
return [str(parsed)]
|
||||
|
||||
return [str(options)]
|
||||
|
||||
def _is_chinese(self, text: str) -> bool:
|
||||
"""Check if text contains Chinese characters.
|
||||
|
||||
|
|
@ -81,7 +58,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
|||
question = args.get("question", "")
|
||||
clarification_type = args.get("clarification_type", "missing_info")
|
||||
context = args.get("context")
|
||||
options = self._normalize_options(args.get("options"))
|
||||
options = args.get("options", [])
|
||||
|
||||
# Type-specific icons
|
||||
type_icons = {
|
||||
|
|
@ -107,7 +84,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
|||
message_parts.append(f"{icon} {question}")
|
||||
|
||||
# Add options in a cleaner format
|
||||
if options:
|
||||
if options and len(options) > 0:
|
||||
message_parts.append("") # blank line for spacing
|
||||
for i, option in enumerate(options, 1):
|
||||
message_parts.append(f" {i}. {option}")
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ from typing import Annotated, NotRequired, TypedDict
|
|||
|
||||
from langchain.agents import AgentState
|
||||
|
||||
ARTIFACTS_REPLACE_SENTINEL = "__deerflow_replace_artifacts__"
|
||||
|
||||
|
||||
class SandboxState(TypedDict):
|
||||
sandbox_id: NotRequired[str | None]
|
||||
|
|
@ -22,22 +20,12 @@ class ViewedImageData(TypedDict):
|
|||
|
||||
def merge_artifacts(existing: list[str] | None, new: list[str] | None) -> list[str]:
|
||||
"""Reducer for artifacts list - merges and deduplicates artifacts."""
|
||||
def _clean(values: list[str] | None) -> list[str]:
|
||||
if not values:
|
||||
return []
|
||||
return [v for v in values if isinstance(v, str) and v != ARTIFACTS_REPLACE_SENTINEL]
|
||||
|
||||
cleaned_existing = _clean(existing)
|
||||
cleaned_new = _clean(new)
|
||||
|
||||
if new and new[0] == ARTIFACTS_REPLACE_SENTINEL:
|
||||
return list(dict.fromkeys(cleaned_new))
|
||||
if existing is None:
|
||||
return cleaned_new
|
||||
return new or []
|
||||
if new is None:
|
||||
return cleaned_existing
|
||||
return existing
|
||||
# Use dict.fromkeys to deduplicate while preserving order
|
||||
return list(dict.fromkeys(cleaned_existing + cleaned_new))
|
||||
return list(dict.fromkeys(existing + new))
|
||||
|
||||
|
||||
def merge_viewed_images(existing: dict[str, ViewedImageData] | None, new: dict[str, ViewedImageData] | None) -> dict[str, ViewedImageData]:
|
||||
|
|
|
|||
|
|
@ -28,11 +28,6 @@ class SubmitRouteConfig(BaseModel):
|
|||
default=None,
|
||||
description="Optional route-level override for billing reserve payload frozenType",
|
||||
)
|
||||
frozen_token: int | None = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
description="Optional route-level override for billing reserve payload estimatedInputTokens/estimatedOutputTokens when frozenType=1",
|
||||
)
|
||||
|
||||
|
||||
class QueryRouteConfig(BaseModel):
|
||||
|
|
@ -61,14 +56,6 @@ class QueryRouteConfig(BaseModel):
|
|||
"E.g. usage.thirdPartyConsumeMoney"
|
||||
),
|
||||
)
|
||||
usage_jsonpaths: list[str] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Optional list of dot-paths into the response body to extract monetary costs and sum them. "
|
||||
"When set, values from all valid paths are added together. "
|
||||
"Example: [\"usage.thirdPartyConsumeMoney\", \"usage.consumeMoney\"]"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ThirdPartyProviderConfig(BaseModel):
|
||||
|
|
@ -101,11 +88,6 @@ class ThirdPartyProviderConfig(BaseModel):
|
|||
default=None,
|
||||
description="Billing frozen type for this provider (frozenType). If omitted, falls back to billing.frozen_type",
|
||||
)
|
||||
frozen_token: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Estimated token amount used for reserve payload when frozenType=1",
|
||||
)
|
||||
submit_routes: list[SubmitRouteConfig] = Field(
|
||||
default_factory=list,
|
||||
description="Route patterns that identify submit (task-create) requests",
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
from types import SimpleNamespace
|
||||
|
||||
from deerflow.agents.middlewares.artifact_reconcile_middleware import (
|
||||
ArtifactReconcileMiddleware,
|
||||
)
|
||||
from deerflow.agents.thread_state import ARTIFACTS_REPLACE_SENTINEL
|
||||
|
||||
|
||||
def test_before_model_prunes_missing_outputs_artifacts(tmp_path):
|
||||
outputs_dir = tmp_path / "outputs"
|
||||
outputs_dir.mkdir()
|
||||
existing = outputs_dir / "keep.md"
|
||||
existing.write_text("ok", encoding="utf-8")
|
||||
|
||||
middleware = ArtifactReconcileMiddleware()
|
||||
state = {
|
||||
"thread_data": {"outputs_path": str(outputs_dir)},
|
||||
"artifacts": [
|
||||
"/mnt/user-data/outputs/keep.md",
|
||||
"/mnt/user-data/outputs/missing.md",
|
||||
],
|
||||
}
|
||||
|
||||
result = middleware.before_model(state, runtime=SimpleNamespace(context={}))
|
||||
|
||||
assert result == {
|
||||
"artifacts": [ARTIFACTS_REPLACE_SENTINEL, "/mnt/user-data/outputs/keep.md"]
|
||||
}
|
||||
|
||||
|
||||
def test_before_model_returns_none_when_no_changes(tmp_path):
|
||||
outputs_dir = tmp_path / "outputs"
|
||||
outputs_dir.mkdir()
|
||||
existing = outputs_dir / "keep.md"
|
||||
existing.write_text("ok", encoding="utf-8")
|
||||
|
||||
middleware = ArtifactReconcileMiddleware()
|
||||
state = {
|
||||
"thread_data": {"outputs_path": str(outputs_dir)},
|
||||
"artifacts": ["/mnt/user-data/outputs/keep.md"],
|
||||
}
|
||||
|
||||
result = middleware.before_model(state, runtime=SimpleNamespace(context={}))
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_before_model_adds_unpresented_outputs_files(tmp_path):
|
||||
outputs_dir = tmp_path / "outputs"
|
||||
outputs_dir.mkdir()
|
||||
existing = outputs_dir / "keep.md"
|
||||
existing.write_text("ok", encoding="utf-8")
|
||||
extra = outputs_dir / "extra.md"
|
||||
extra.write_text("ok", encoding="utf-8")
|
||||
|
||||
middleware = ArtifactReconcileMiddleware()
|
||||
state = {
|
||||
"thread_data": {"outputs_path": str(outputs_dir)},
|
||||
"artifacts": ["/mnt/user-data/outputs/keep.md"],
|
||||
}
|
||||
|
||||
result = middleware.before_model(state, runtime=SimpleNamespace(context={}))
|
||||
|
||||
assert result == {
|
||||
"artifacts": [
|
||||
ARTIFACTS_REPLACE_SENTINEL,
|
||||
"/mnt/user-data/outputs/keep.md",
|
||||
"/mnt/user-data/outputs/extra.md",
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_before_model_discovers_outputs_when_artifacts_empty(tmp_path):
|
||||
outputs_dir = tmp_path / "outputs"
|
||||
outputs_dir.mkdir()
|
||||
report = outputs_dir / "report.md"
|
||||
report.write_text("ok", encoding="utf-8")
|
||||
|
||||
middleware = ArtifactReconcileMiddleware()
|
||||
state = {
|
||||
"thread_data": {"outputs_path": str(outputs_dir)},
|
||||
"artifacts": [],
|
||||
}
|
||||
|
||||
result = middleware.before_model(state, runtime=SimpleNamespace(context={}))
|
||||
|
||||
assert result == {
|
||||
"artifacts": [ARTIFACTS_REPLACE_SENTINEL, "/mnt/user-data/outputs/report.md"]
|
||||
}
|
||||
|
||||
|
||||
def test_before_model_drops_leaked_replace_sentinel(tmp_path):
|
||||
outputs_dir = tmp_path / "outputs"
|
||||
outputs_dir.mkdir()
|
||||
keep = outputs_dir / "keep.md"
|
||||
keep.write_text("ok", encoding="utf-8")
|
||||
|
||||
middleware = ArtifactReconcileMiddleware()
|
||||
state = {
|
||||
"thread_data": {"outputs_path": str(outputs_dir)},
|
||||
"artifacts": [
|
||||
ARTIFACTS_REPLACE_SENTINEL,
|
||||
"/mnt/user-data/outputs/keep.md",
|
||||
],
|
||||
}
|
||||
|
||||
result = middleware.before_model(state, runtime=SimpleNamespace(context={}))
|
||||
|
||||
assert result == {
|
||||
"artifacts": [ARTIFACTS_REPLACE_SENTINEL, "/mnt/user-data/outputs/keep.md"]
|
||||
}
|
||||
|
|
@ -130,43 +130,3 @@ def test_get_artifact_compat_fallback_for_dash_spacing(tmp_path, monkeypatch) ->
|
|||
|
||||
assert bytes(response.body).decode("utf-8") == "ok"
|
||||
assert response.media_type == "text/markdown"
|
||||
|
||||
|
||||
def test_list_reference_files_returns_outputs_and_uploads(tmp_path, monkeypatch) -> None:
|
||||
outputs_dir = tmp_path / "outputs"
|
||||
uploads_dir = tmp_path / "uploads"
|
||||
outputs_dir.mkdir()
|
||||
uploads_dir.mkdir()
|
||||
(outputs_dir / "notes.md").write_text("hello", encoding="utf-8")
|
||||
(outputs_dir / "figures").mkdir()
|
||||
(outputs_dir / "figures" / "plot.png").write_bytes(b"png")
|
||||
(uploads_dir / "dataset.csv").write_text("a,b\n1,2\n", encoding="utf-8")
|
||||
(uploads_dir / "skill").mkdir()
|
||||
(uploads_dir / "skill" / "internal.txt").write_text("hidden", encoding="utf-8")
|
||||
|
||||
class _FakePaths:
|
||||
def sandbox_outputs_dir(self, _thread_id: str) -> Path:
|
||||
return outputs_dir
|
||||
|
||||
def sandbox_uploads_dir(self, _thread_id: str) -> Path:
|
||||
return uploads_dir
|
||||
|
||||
monkeypatch.setattr(artifacts_router, "get_paths", lambda: _FakePaths())
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(artifacts_router.router)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/threads/thread-1/artifacts/list")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["count"] == 3
|
||||
by_path = {item["virtual_path"]: item for item in payload["files"]}
|
||||
|
||||
assert "/mnt/user-data/outputs/notes.md" in by_path
|
||||
assert "/mnt/user-data/outputs/figures/plot.png" in by_path
|
||||
assert "/mnt/user-data/uploads/dataset.csv" in by_path
|
||||
assert "/mnt/user-data/uploads/skill/internal.txt" not in by_path
|
||||
assert by_path["/mnt/user-data/outputs/notes.md"]["source"] == "artifact"
|
||||
assert by_path["/mnt/user-data/uploads/dataset.csv"]["source"] == "upload"
|
||||
|
|
|
|||
|
|
@ -147,8 +147,7 @@ def test_create_summarization_middleware_uses_configured_model_alias(monkeypatch
|
|||
)
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
fake_model = MagicMock()
|
||||
fake_model._llm_type = "test-chat"
|
||||
fake_model = object()
|
||||
|
||||
def _fake_create_chat_model(*, name=None, thinking_enabled, reasoning_effort=None):
|
||||
captured["name"] = name
|
||||
|
|
@ -157,20 +156,10 @@ def test_create_summarization_middleware_uses_configured_model_alias(monkeypatch
|
|||
return fake_model
|
||||
|
||||
monkeypatch.setattr(lead_agent_module, "create_chat_model", _fake_create_chat_model)
|
||||
monkeypatch.setattr(lead_agent_module, "SummarizationMiddleware", lambda **kwargs: kwargs)
|
||||
|
||||
middleware = lead_agent_module._create_summarization_middleware()
|
||||
|
||||
assert captured["name"] == "model-masswork"
|
||||
assert captured["thinking_enabled"] is False
|
||||
assert isinstance(middleware, lead_agent_module.DeerFlowSummarizationMiddleware)
|
||||
assert middleware.model is fake_model
|
||||
|
||||
|
||||
def test_deerflow_summarization_middleware_uses_chinese_summary_title():
|
||||
middleware = lead_agent_module.DeerFlowSummarizationMiddleware(
|
||||
model=MagicMock(),
|
||||
trigger=("messages", 2),
|
||||
)
|
||||
|
||||
messages = middleware._build_new_messages("旧上下文")
|
||||
|
||||
assert messages[0].content == "以下是目前对话的摘要:\n\n旧上下文"
|
||||
assert middleware["model"] is fake_model
|
||||
|
|
|
|||
|
|
@ -3,16 +3,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from app.gateway.third_party_proxy.ledger import CallLedger
|
||||
from app.gateway.routers.third_party import (
|
||||
_extract_usage_tokens,
|
||||
_extract_usage_tokens_from_submit_stream,
|
||||
_resolve_final_amount,
|
||||
)
|
||||
from app.gateway.third_party_proxy.proxy import (
|
||||
API_KEY_MARKER,
|
||||
_path_matches,
|
||||
_replace_api_key_marker_in_body,
|
||||
_replace_api_key_marker_in_headers,
|
||||
jsonpath_get,
|
||||
match_query_route,
|
||||
match_submit_route,
|
||||
|
|
@ -107,7 +99,6 @@ _PROVIDER_CFG = ThirdPartyProviderConfig(
|
|||
success_values=["SUCCESS"],
|
||||
failure_values=["FAILED", "CANCELLED"],
|
||||
usage_jsonpath="usage.thirdPartyConsumeMoney",
|
||||
usage_jsonpaths=["usage.thirdPartyConsumeMoney", "usage.consumeMoney"],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
|
@ -199,94 +190,3 @@ class TestCallLedger:
|
|||
ledger.update_response(rec.proxy_call_id, {"result": "ok"})
|
||||
found = ledger.get(rec.proxy_call_id)
|
||||
assert found.last_response == {"result": "ok"}
|
||||
|
||||
|
||||
class TestResolveFinalAmount:
|
||||
def test_sum_multiple_usage_paths(self):
|
||||
route = QueryRouteConfig(
|
||||
path_pattern="/openapi/v2/query",
|
||||
request_task_id_jsonpath="taskId",
|
||||
status_jsonpath="status",
|
||||
success_values=["SUCCESS"],
|
||||
failure_values=["FAILED"],
|
||||
usage_jsonpaths=["usage.thirdPartyConsumeMoney", "usage.consumeMoney"],
|
||||
)
|
||||
resp_json = {
|
||||
"usage": {
|
||||
"thirdPartyConsumeMoney": None,
|
||||
"consumeMoney": "0.099",
|
||||
}
|
||||
}
|
||||
amount = _resolve_final_amount(resp_json, route)
|
||||
assert amount == 0.099
|
||||
|
||||
def test_fallback_to_legacy_single_usage_path(self):
|
||||
route = QueryRouteConfig(
|
||||
path_pattern="/openapi/v2/query",
|
||||
request_task_id_jsonpath="taskId",
|
||||
status_jsonpath="status",
|
||||
success_values=["SUCCESS"],
|
||||
failure_values=["FAILED"],
|
||||
usage_jsonpath="usage.thirdPartyConsumeMoney",
|
||||
)
|
||||
resp_json = {"usage": {"thirdPartyConsumeMoney": "1.5"}}
|
||||
amount = _resolve_final_amount(resp_json, route)
|
||||
assert amount == 1.5
|
||||
|
||||
|
||||
class TestExtractUsageTokens:
|
||||
def test_prefers_openai_usage_keys(self):
|
||||
resp_json = {
|
||||
"usage": {
|
||||
"prompt_tokens": 123,
|
||||
"completion_tokens": 45,
|
||||
}
|
||||
}
|
||||
input_tokens, output_tokens = _extract_usage_tokens(resp_json)
|
||||
assert input_tokens == 123
|
||||
assert output_tokens == 45
|
||||
|
||||
def test_supports_generic_usage_keys(self):
|
||||
resp_json = {
|
||||
"usage": {
|
||||
"input_tokens": "88",
|
||||
"output_tokens": "12",
|
||||
}
|
||||
}
|
||||
input_tokens, output_tokens = _extract_usage_tokens(resp_json)
|
||||
assert input_tokens == 88
|
||||
assert output_tokens == 12
|
||||
|
||||
|
||||
class TestExtractUsageTokensFromSubmitStream:
|
||||
def test_extracts_usage_from_final_sse_chunk(self):
|
||||
body = (
|
||||
b'data: {"id":"x","choices":[{"delta":{"content":"hello"}}]}\n\n'
|
||||
b'data: {"id":"x","choices":[],"usage":{"prompt_tokens":22,"completion_tokens":17}}\n\n'
|
||||
b'data: [DONE]\n\n'
|
||||
)
|
||||
input_tokens, output_tokens = _extract_usage_tokens_from_submit_stream(body)
|
||||
assert input_tokens == 22
|
||||
assert output_tokens == 17
|
||||
|
||||
def test_returns_zero_when_no_usage_found(self):
|
||||
body = b'data: {"id":"x","choices":[{"delta":{"content":"hello"}}]}\n\n'
|
||||
input_tokens, output_tokens = _extract_usage_tokens_from_submit_stream(body)
|
||||
assert input_tokens == 0
|
||||
assert output_tokens == 0
|
||||
|
||||
|
||||
class TestApiKeyMarkerReplacement:
|
||||
def test_replace_marker_in_headers(self):
|
||||
headers = {"Authorization": f"Bearer {API_KEY_MARKER}", "Content-Type": "application/json"}
|
||||
replaced = _replace_api_key_marker_in_headers(headers, "real-key")
|
||||
assert replaced["Authorization"] == "Bearer real-key"
|
||||
|
||||
def test_replace_marker_in_json_body(self):
|
||||
headers = {"Content-Type": "application/json"}
|
||||
body = (
|
||||
b'{"apiKey":"__API_KEY_MARKER__","nested":{"token":"Bearer __API_KEY_MARKER__"}}'
|
||||
)
|
||||
replaced = _replace_api_key_marker_in_body(headers, body, "real-key")
|
||||
assert b'"apiKey":"real-key"' in replaced
|
||||
assert b'"token":"Bearer real-key"' in replaced
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
from deerflow.agents.thread_state import (
|
||||
ARTIFACTS_REPLACE_SENTINEL,
|
||||
merge_artifacts,
|
||||
)
|
||||
|
||||
|
||||
def test_merge_artifacts_default_merge_dedup():
|
||||
existing = ["/mnt/user-data/outputs/a.md", "/mnt/user-data/outputs/b.md"]
|
||||
new = ["/mnt/user-data/outputs/b.md", "/mnt/user-data/outputs/c.md"]
|
||||
|
||||
result = merge_artifacts(existing, new)
|
||||
|
||||
assert result == [
|
||||
"/mnt/user-data/outputs/a.md",
|
||||
"/mnt/user-data/outputs/b.md",
|
||||
"/mnt/user-data/outputs/c.md",
|
||||
]
|
||||
|
||||
|
||||
def test_merge_artifacts_supports_replace_sentinel():
|
||||
existing = ["/mnt/user-data/outputs/a.md", "/mnt/user-data/outputs/b.md"]
|
||||
new = [
|
||||
ARTIFACTS_REPLACE_SENTINEL,
|
||||
"/mnt/user-data/outputs/b.md",
|
||||
"/mnt/user-data/outputs/c.md",
|
||||
"/mnt/user-data/outputs/c.md",
|
||||
]
|
||||
|
||||
result = merge_artifacts(existing, new)
|
||||
|
||||
assert result == [
|
||||
"/mnt/user-data/outputs/b.md",
|
||||
"/mnt/user-data/outputs/c.md",
|
||||
]
|
||||
|
||||
|
||||
def test_merge_artifacts_always_strips_sentinel_from_existing():
|
||||
existing = [
|
||||
"/mnt/user-data/outputs/a.md",
|
||||
ARTIFACTS_REPLACE_SENTINEL,
|
||||
"/mnt/user-data/outputs/b.md",
|
||||
]
|
||||
|
||||
result = merge_artifacts(existing, None)
|
||||
|
||||
assert result == [
|
||||
"/mnt/user-data/outputs/a.md",
|
||||
"/mnt/user-data/outputs/b.md",
|
||||
]
|
||||
|
||||
|
||||
def test_merge_artifacts_strips_sentinel_from_non_replace_payload():
|
||||
existing = ["/mnt/user-data/outputs/a.md"]
|
||||
new = [
|
||||
"/mnt/user-data/outputs/b.md",
|
||||
ARTIFACTS_REPLACE_SENTINEL,
|
||||
"/mnt/user-data/outputs/c.md",
|
||||
]
|
||||
|
||||
result = merge_artifacts(existing, new)
|
||||
|
||||
assert result == [
|
||||
"/mnt/user-data/outputs/a.md",
|
||||
"/mnt/user-data/outputs/b.md",
|
||||
"/mnt/user-data/outputs/c.md",
|
||||
]
|
||||
|
|
@ -56,7 +56,7 @@ billing:
|
|||
# third-party async task APIs such as RunningHub.
|
||||
|
||||
third_party_proxy:
|
||||
enabled: true
|
||||
enabled: false
|
||||
providers:
|
||||
runninghub:
|
||||
base_url: https://www.runninghub.cn
|
||||
|
|
@ -64,53 +64,35 @@ third_party_proxy:
|
|||
api_key_header: Authorization
|
||||
api_key_prefix: "Bearer "
|
||||
timeout_seconds: 30.0
|
||||
frozen_amount: 10.0
|
||||
frozen_type: 2
|
||||
submit_routes:
|
||||
- path_pattern: "/openapi/v2/rhart-image/z-image/turbo-lora"
|
||||
- path_pattern: "/openapi/v2/**"
|
||||
exclude_path_pattern: "/openapi/v2/query"
|
||||
task_id_jsonpath: "taskId"
|
||||
frozen_amount: 0.03
|
||||
frozen_type: 2
|
||||
- path_pattern: "/openapi/v2/rhart-image-g-2/text-to-image"
|
||||
task_id_jsonpath: "taskId"
|
||||
frozen_amount: 0.2
|
||||
frozen_type: 2
|
||||
- path_pattern: "/openapi/v2/rhart-image-g-2/image-to-image"
|
||||
task_id_jsonpath: "taskId"
|
||||
frozen_amount: 0.2
|
||||
frozen_type: 2
|
||||
- path_pattern: "/openapi/v2/rhart-audio/text-to-audio/speech-2.8-turbo"
|
||||
task_id_jsonpath: "taskId"
|
||||
frozen_amount: 1.85
|
||||
frozen_type: 2
|
||||
- path_pattern: "/task/openapi/create"
|
||||
task_id_jsonpath: "data.taskId"
|
||||
frozen_amount: 2.0
|
||||
frozen_type: 2
|
||||
- path_pattern: "/openapi/v2/vidu/text-to-video-q3-turbo"
|
||||
task_id_jsonpath: "taskId"
|
||||
frozen_amount: 11.2
|
||||
frozen_type: 2
|
||||
# Optional per-model billing override examples:
|
||||
# frozen_amount: 10.0
|
||||
# frozen_type: 2
|
||||
|
||||
# Example: model-specific reserve policy
|
||||
# - path_pattern: "/openapi/v2/rhart-image/z-image/turbo-lora"
|
||||
# task_id_jsonpath: "taskId"
|
||||
# frozen_amount: 10.0
|
||||
# frozen_type: 2
|
||||
# - path_pattern: "/openapi/v2/vidu/text-to-video-q3-turbo"
|
||||
# task_id_jsonpath: "taskId"
|
||||
# frozen_amount: 50.0
|
||||
# frozen_type: 2
|
||||
# - path_pattern: "/openapi/v2/wan-2.7/image-edit"
|
||||
# task_id_jsonpath: "taskId"
|
||||
# frozen_amount: 20.0
|
||||
# frozen_type: 2
|
||||
query_routes:
|
||||
- path_pattern: "/openapi/v2/query"
|
||||
request_task_id_jsonpath: "taskId"
|
||||
status_jsonpath: "status"
|
||||
success_values: ["SUCCESS"]
|
||||
failure_values: ["FAILED", "CANCELLED"]
|
||||
usage_jsonpaths: ["usage.thirdPartyConsumeMoney", "usage.consumeMoney"]
|
||||
|
||||
dashscope:
|
||||
base_url: https://dashscope.aliyuncs.com
|
||||
api_key_env: DASHSCOPE_API_KEY
|
||||
api_key_header: Authorization
|
||||
api_key_prefix: "Bearer "
|
||||
timeout_seconds: 60.0
|
||||
frozen_token: 32768
|
||||
submit_routes:
|
||||
- path_pattern: "/compatible-mode/v1/chat/completions"
|
||||
task_id_jsonpath: "id"
|
||||
frozen_type: 1
|
||||
query_routes: []
|
||||
usage_jsonpath: "usage.thirdPartyConsumeMoney"
|
||||
|
||||
# ============================================================================
|
||||
# Token Usage Tracking
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ The examples below assume Python + requests.
|
|||
Add:
|
||||
- `load_skill_env()`: loads skill-local `.env`
|
||||
- `get_gateway_config()`: reads
|
||||
- `DEER_FLOW_GATEWAY_URL` (default `http://host.docker.internal:8001`)
|
||||
- `DEER_FLOW_GATEWAY_URL` (default `http://host.docker.internal:8101`)
|
||||
- `RUNNINGHUB_PROXY_PROVIDER` (default `runninghub`)
|
||||
|
||||
### Step 2: Centralize proxy headers
|
||||
|
|
@ -83,25 +83,6 @@ Recommended checks:
|
|||
- submit fallback when `taskId` is missing
|
||||
- query loop timeout/failure handling
|
||||
|
||||
### Step 7: Clean API key instructions from skill.md
|
||||
|
||||
After migrating a skill to gateway proxy, remove any user-facing instructions in `skill.md` that ask users to configure third-party provider keys (for example, `RUNNINGHUB_API_KEY`).
|
||||
|
||||
What to remove from `skill.md`:
|
||||
- "Set `RUNNINGHUB_API_KEY` in .env"
|
||||
- "Create an API key on provider platform"
|
||||
- Any step that tells users to pass `Authorization: Bearer ...`
|
||||
|
||||
What to keep/add in `skill.md`:
|
||||
- Mention that third-party credentials are handled by gateway config
|
||||
- Keep only skill runtime inputs (prompt, output path, optional style/duration)
|
||||
- Optionally mention gateway-related vars if needed by local debugging:
|
||||
- `DEER_FLOW_GATEWAY_URL`
|
||||
- `RUNNINGHUB_PROXY_PROVIDER`
|
||||
|
||||
Suggested replacement sentence:
|
||||
- "This skill uses DeerFlow Gateway third-party proxy. Provider credentials are configured centrally in gateway and are not required in this skill's local `.env`."
|
||||
|
||||
## 4. Proxy Config Migration (config.yaml)
|
||||
|
||||
Configure submit/query routes under `third_party_proxy.providers.<provider>`.
|
||||
|
|
@ -212,7 +193,6 @@ For Docker-based sandbox execution, use:
|
|||
5. Config extraction fields use shorthand dot-paths only.
|
||||
6. Submit returns `taskId`, then query reaches `RUNNING/SUCCESS`.
|
||||
7. Gateway logs show submit/query route hits and finalize flow.
|
||||
8. `skill.md` no longer contains instructions to configure third-party API keys.
|
||||
|
||||
## 8. Reference Implementations
|
||||
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ async function validateTokenRegistry() {
|
|||
const darkSeen = new Map();
|
||||
|
||||
for (const [name, value] of entries) {
|
||||
if (!/^ws-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
|
||||
if (!/^ws-[0-9a-f]{6,8}$/.test(name)) {
|
||||
errors.push(`invalid token name "${name}"`);
|
||||
}
|
||||
const light = String(value.light ?? "").toLowerCase();
|
||||
|
|
@ -234,7 +234,7 @@ function collectWsVarsFromBlocks(css, selectorPattern) {
|
|||
const selector = block[1]?.trim() ?? "";
|
||||
const body = block[2] ?? "";
|
||||
if (!selectorPattern.test(selector)) continue;
|
||||
for (const match of body.matchAll(/--ws-color-([0-9a-z-]+)\s*:/g)) {
|
||||
for (const match of body.matchAll(/--ws-color-([0-9a-z]+)\s*:/g)) {
|
||||
vars.add(`ws-${match[1]}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -246,7 +246,7 @@ function validateGlobalsCoverage(tokenEntries) {
|
|||
const rootVars = collectWsVarsFromBlocks(css, /(^|,)\s*:root(\s|,|$)/);
|
||||
const darkVars = collectWsVarsFromBlocks(css, /(^|,)\s*\.dark(\s|,|$)/);
|
||||
const inlineVars = new Set(
|
||||
[...css.matchAll(/--color-ws-([0-9a-z-]+)\s*:/g)].map((match) => `ws-${match[1]}`),
|
||||
[...css.matchAll(/--color-ws-([0-9a-z]+)\s*:/g)].map((match) => `ws-${match[1]}`),
|
||||
);
|
||||
const tokenNames = new Set(tokenEntries.map(([name]) => name));
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ import { Tooltip } from "@/components/workspace/tooltip";
|
|||
import { useSpecificChatMode } from "@/components/workspace/use-chat-mode";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { sanitizeArtifactPaths } from "@/core/artifacts/utils";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
|
|
@ -81,30 +80,23 @@ export default function ChatPage() {
|
|||
() => isNewThread && !safeThreadId,
|
||||
[isNewThread, safeThreadId],
|
||||
);
|
||||
const [isThreadInitReady, setIsThreadInitReady] = useState(false);
|
||||
|
||||
const streamThreadId = useMemo(() => {
|
||||
if (!safeThreadId) {
|
||||
return undefined;
|
||||
}
|
||||
// In /new flow, defer history loading until thread init is finished:
|
||||
// delete -> create -> history.
|
||||
if (isNewThread && !isThreadInitReady) {
|
||||
if (isNewThread && createNewSession) {
|
||||
return undefined;
|
||||
}
|
||||
return safeThreadId;
|
||||
}, [isNewThread, isThreadInitReady, safeThreadId]);
|
||||
}, [createNewSession, isNewThread, safeThreadId]);
|
||||
const apiClient = useMemo(() => getAPIClient(isMock), [isMock]);
|
||||
const warnedMissingThreadIdRef = useRef(false);
|
||||
const initializedThreadRef = useRef<string | null>(null);
|
||||
const threadInitPromiseRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const currentSlogan = motivationSlogans[
|
||||
sloganIndex % motivationSlogans.length
|
||||
] ?? {
|
||||
text: t.chatPage.defaultSlogan,
|
||||
color: "var(--color-ws-fg-primary)",
|
||||
color: "var(--color-ws-333333)",
|
||||
};
|
||||
const tickerCharacterList = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
|
|
@ -137,7 +129,6 @@ export default function ChatPage() {
|
|||
useEffect(() => {
|
||||
if (!isNewThread) {
|
||||
warnedMissingThreadIdRef.current = false;
|
||||
setIsThreadInitReady(true);
|
||||
return;
|
||||
}
|
||||
if (!safeThreadId) {
|
||||
|
|
@ -145,38 +136,29 @@ export default function ChatPage() {
|
|||
warnedMissingThreadIdRef.current = true;
|
||||
toast.error(t.chatPage.missingThreadIdForCreate);
|
||||
}
|
||||
setIsThreadInitReady(false);
|
||||
return;
|
||||
}
|
||||
warnedMissingThreadIdRef.current = false;
|
||||
if (initializedThreadRef.current === safeThreadId) return;
|
||||
initializedThreadRef.current = safeThreadId;
|
||||
setIsThreadInitReady(false);
|
||||
|
||||
const initPromise = apiClient.threads
|
||||
.delete(safeThreadId)
|
||||
.catch(() => undefined)
|
||||
.then(() =>
|
||||
apiClient.threads.create({
|
||||
threadId: safeThreadId,
|
||||
ifExists: "do_nothing",
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
setIsThreadInitReady(true);
|
||||
void apiClient.threads
|
||||
// TODO: 先注释先删除再创建的逻辑
|
||||
// .delete(safeThreadId)
|
||||
// .catch(() => undefined)
|
||||
// .then(() =>
|
||||
// apiClient.threads.create({
|
||||
// threadId: safeThreadId,
|
||||
// ifExists: "raise",
|
||||
// }),
|
||||
// )
|
||||
.create({
|
||||
threadId: safeThreadId,
|
||||
ifExists: "do_nothing",
|
||||
})
|
||||
.catch(() => {
|
||||
initializedThreadRef.current = null;
|
||||
setIsThreadInitReady(false);
|
||||
toast.error(t.chatPage.createSessionFailed);
|
||||
});
|
||||
|
||||
threadInitPromiseRef.current = initPromise;
|
||||
void initPromise.finally(() => {
|
||||
if (threadInitPromiseRef.current === initPromise) {
|
||||
threadInitPromiseRef.current = null;
|
||||
}
|
||||
});
|
||||
}, [
|
||||
apiClient,
|
||||
isNewThread,
|
||||
|
|
@ -228,10 +210,6 @@ export default function ChatPage() {
|
|||
const result = thread.values?.title ?? "";
|
||||
return result === "Untitled" ? "" : result;
|
||||
}, [thread.values?.title]);
|
||||
const sanitizedArtifacts = useMemo(
|
||||
() => sanitizeArtifactPaths(thread.values.artifacts),
|
||||
[thread.values.artifacts],
|
||||
);
|
||||
|
||||
const [hasSubmitted, setHasSubmitted] = useState(false);
|
||||
const [historyCutoff, setHistoryCutoff] = useState<number | null>(null);
|
||||
|
|
@ -280,21 +258,21 @@ export default function ChatPage() {
|
|||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
useEffect(() => {
|
||||
setArtifacts(sanitizedArtifacts);
|
||||
setArtifacts(thread.values.artifacts);
|
||||
if (
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||
autoSelectFirstArtifact
|
||||
) {
|
||||
if (sanitizedArtifacts.length > 0) {
|
||||
if (thread?.values?.artifacts?.length > 0) {
|
||||
setAutoSelectFirstArtifact(false);
|
||||
selectArtifact(sanitizedArtifacts[0]!);
|
||||
selectArtifact(thread.values.artifacts[0]!);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
autoSelectFirstArtifact,
|
||||
sanitizedArtifacts,
|
||||
selectArtifact,
|
||||
setArtifacts,
|
||||
thread.values.artifacts,
|
||||
]);
|
||||
|
||||
const artifactPanelOpen = useMemo(() => {
|
||||
|
|
@ -308,7 +286,7 @@ export default function ChatPage() {
|
|||
const [showExitDialog, setShowExitDialog] = useState(false);
|
||||
const isStreaming = isUploading || thread.isLoading;
|
||||
const handleSubmit = useCallback(
|
||||
async (message: Parameters<typeof sendMessage>[1]) => {
|
||||
(message: Parameters<typeof sendMessage>[1]) => {
|
||||
if (isSelectedSkillBootstrapping) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -316,12 +294,6 @@ export default function ChatPage() {
|
|||
toast.error(t.chatPage.missingThreadIdForSend);
|
||||
return;
|
||||
}
|
||||
if (isNewThread && safeThreadId) {
|
||||
await threadInitPromiseRef.current;
|
||||
}
|
||||
if (isNewThread && safeThreadId && !isThreadInitReady) {
|
||||
return;
|
||||
}
|
||||
setHasSubmitted(true);
|
||||
if (safeThreadId && (isNewThread || showWelcomeStyle)) {
|
||||
router.replace(`/workspace/chats/${safeThreadId}?is_chatting=true`);
|
||||
|
|
@ -330,7 +302,6 @@ export default function ChatPage() {
|
|||
},
|
||||
[
|
||||
isNewThread,
|
||||
isThreadInitReady,
|
||||
isSelectedSkillBootstrapping,
|
||||
router,
|
||||
safeThreadId,
|
||||
|
|
@ -386,7 +357,7 @@ export default function ChatPage() {
|
|||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="px-[10px] py-[5px] text-sm font-medium text-ws-base-1 hover:text-ws-base-1/80"
|
||||
className="px-[10px] py-[5px] text-sm font-medium text-ws-150033 hover:text-ws-150033/80"
|
||||
disabled={isStreaming}
|
||||
onClick={() => setShowExitDialog(true)}
|
||||
>
|
||||
|
|
@ -399,7 +370,7 @@ export default function ChatPage() {
|
|||
>
|
||||
<path
|
||||
d="M3.5 10H13.25H15.6875H16.5M3.5 10L7.5625 6M3.5 10L7.5625 14"
|
||||
className="text-ws-text-muted"
|
||||
className="text-ws-667085"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
|
|
@ -409,7 +380,7 @@ export default function ChatPage() {
|
|||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-center overflow-hidden text-sm font-bold font-medium whitespace-nowrap text-ws-fg-primary"
|
||||
className="flex items-center justify-center overflow-hidden text-sm font-bold font-medium whitespace-nowrap text-ws-333333"
|
||||
style={{
|
||||
color: currentSlogan.color,
|
||||
}}
|
||||
|
|
@ -429,7 +400,7 @@ export default function ChatPage() {
|
|||
<div className="flex items-center justify-end gap-2 overflow-hidden">
|
||||
{/* 取消TodoList */}
|
||||
{/* <DevTodoList
|
||||
className="bg-ws-surface-base"
|
||||
className="bg-ws-ffffff"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos || thread.values.todos.length === 0
|
||||
|
|
@ -438,7 +409,7 @@ export default function ChatPage() {
|
|||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-full px-[10px] py-[5px] text-sm font-medium text-ws-base-1 hover:text-ws-base-1"
|
||||
className="h-full px-[10px] py-[5px] text-sm font-medium text-ws-150033 hover:text-ws-150033"
|
||||
>
|
||||
<ListTodoIcon className="size-4" /> To-dos
|
||||
</Button>
|
||||
|
|
@ -449,7 +420,7 @@ export default function ChatPage() {
|
|||
<Tooltip content={t.chatPage.viewArtifactsTooltip}>
|
||||
<Button
|
||||
data-testid="artifacts-open-button"
|
||||
className="text-ws-base-1 hover:text-ws-base-1/80"
|
||||
className="text-ws-150033 hover:text-ws-150033/80"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setArtifactsOpen(true);
|
||||
|
|
@ -467,7 +438,7 @@ export default function ChatPage() {
|
|||
className={cn(
|
||||
"flex min-h-0 max-w-full grow flex-col",
|
||||
showWelcomeStyle && !hasSubmitted
|
||||
? "bg-ws-surface-base"
|
||||
? "bg-ws-ffffff"
|
||||
: "bg-background",
|
||||
)}
|
||||
>
|
||||
|
|
@ -521,7 +492,7 @@ export default function ChatPage() {
|
|||
) : (
|
||||
<div className="relative flex size-full justify-center px-[20px]">
|
||||
<div className="z-30"></div>
|
||||
{sanitizedArtifacts.length === 0 ? (
|
||||
{thread.values.artifacts?.length === 0 ? (
|
||||
<ConversationEmptyState
|
||||
icon={<FilesIcon />}
|
||||
title={t.chatPage.noArtifactSelectedTitle}
|
||||
|
|
@ -530,7 +501,7 @@ export default function ChatPage() {
|
|||
) : (
|
||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center">
|
||||
<header className="flex shrink-0 items-center justify-between border-b">
|
||||
<h2 className="h-[58px] text-sm leading-[58px] font-bold text-ws-fg-primary">
|
||||
<h2 className="h-[58px] text-sm leading-[58px] font-bold text-ws-333333">
|
||||
<span>{t.common.artifacts}</span>
|
||||
</h2>
|
||||
<Button
|
||||
|
|
@ -547,7 +518,7 @@ export default function ChatPage() {
|
|||
<main className="min-h-0 grow overflow-auto">
|
||||
<ArtifactFileList
|
||||
className="mb-[207px] max-w-(--container-width-sm) pt-[20px]"
|
||||
files={sanitizedArtifacts}
|
||||
files={thread.values.artifacts ?? []}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</main>
|
||||
|
|
@ -578,7 +549,7 @@ export default function ChatPage() {
|
|||
{!(showWelcomeStyle && thread.isThreadLoading) ? (
|
||||
<>
|
||||
<InputBox
|
||||
className={cn("w-full rounded-[20px] bg-ws-surface-elevated")}
|
||||
className={cn("w-full rounded-[20px] bg-ws-fbfafc")}
|
||||
threadId={threadId}
|
||||
showWelcomeStyle={showWelcomeStyle}
|
||||
hasSubmitted={hasSubmitted}
|
||||
|
|
@ -638,14 +609,14 @@ export default function ChatPage() {
|
|||
</p>
|
||||
<DevDialogFooter>
|
||||
<Button
|
||||
className="w-full bg-ws-surface-subtle hover:bg-ws-interactive-primary hover:text-primary-foreground"
|
||||
className="w-full bg-ws-f9f8fa hover:bg-ws-8e47f0 hover:text-primary-foreground"
|
||||
variant="ghost"
|
||||
onClick={() => setShowExitDialog(false)}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full bg-ws-surface-subtle hover:bg-ws-interactive-primary hover:text-primary-foreground"
|
||||
className="w-full bg-ws-f9f8fa hover:bg-ws-8e47f0 hover:text-primary-foreground"
|
||||
variant="ghost"
|
||||
onClick={async () => {
|
||||
// 如果正在生成,先终止再退出
|
||||
|
|
@ -657,14 +628,14 @@ export default function ChatPage() {
|
|||
type: POST_MESSAGE_TYPES.IS_CHATTING,
|
||||
isChatting: false,
|
||||
});
|
||||
resetNewSessionState();
|
||||
// 始终复用 query 中的 thread_id。
|
||||
const nextQuery = new URLSearchParams();
|
||||
if (threadId && threadId !== "new") {
|
||||
nextQuery.set("thread_id", threadId);
|
||||
}
|
||||
// /workspace/chats/${threadId}?is_chatting=false
|
||||
router.replace(
|
||||
`/workspace/chats/new?thread_id=${threadId}`,
|
||||
`/workspace/chats/${threadId}?is_chatting=false`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
|
@ -694,7 +665,7 @@ export default function ChatPage() {
|
|||
</p>
|
||||
<DevDialogFooter singleColumn>
|
||||
<Button
|
||||
className="w-full bg-ws-surface-subtle hover:bg-ws-interactive-primary hover:text-primary-foreground"
|
||||
className="w-full bg-ws-f9f8fa hover:bg-ws-8e47f0 hover:text-primary-foreground"
|
||||
variant="ghost"
|
||||
onClick={clearSelectedSkillError}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ export default function WorkspaceLayout({
|
|||
/* 灰色圆角矩形容器 */
|
||||
"rounded-[20px] border-none",
|
||||
/* 浅灰色背景 + 轻微透明 */
|
||||
"bg-ws-overlay-neutral! backdrop-blur-sm",
|
||||
"bg-ws-999999! backdrop-blur-sm",
|
||||
/* 阴影极轻 */
|
||||
"shadow-[0_2px_12px_0_rgba(0,0,0,0.18)]",
|
||||
/* 内边距:宽松居中 */
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export const Message = ({
|
|||
"group flex w-full flex-col gap-2",
|
||||
from === "user"
|
||||
? cn("is-user ml-auto justify-end", !isFirstInSession && "mt-6")
|
||||
: "is-assistant rounded-[10px] bg-ws-surface-base p-4",
|
||||
: "is-assistant rounded-[10px] bg-ws-ffffff p-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -352,7 +352,7 @@ export function PromptInputAttachment({
|
|||
{/* 删除按钮 - 右上角 */}
|
||||
<button
|
||||
aria-label={t.common.removeAttachment}
|
||||
className="absolute top-1.5 right-1.5 z-10 flex size-4 cursor-pointer items-center justify-center rounded-sm transition-colors hover:bg-ws-surface-base/20"
|
||||
className="absolute top-1.5 right-1.5 z-10 flex size-4 cursor-pointer items-center justify-center rounded-sm transition-colors hover:bg-ws-ffffff/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onRemove) {
|
||||
|
|
@ -397,7 +397,7 @@ export function PromptInputAttachment({
|
|||
{/* 关闭按钮 - 右上角 */}
|
||||
<button
|
||||
aria-label={t.common.removeAttachment}
|
||||
className="absolute top-1 right-1 z-10 flex size-5 cursor-pointer items-center justify-center rounded bg-ws-surface-base/90 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-ws-surface-base dark:bg-gray-800/90 dark:hover:bg-gray-800"
|
||||
className="absolute top-1 right-1 z-10 flex size-5 cursor-pointer items-center justify-center rounded bg-ws-ffffff/90 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-ws-ffffff dark:bg-gray-800/90 dark:hover:bg-gray-800"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onRemove) {
|
||||
|
|
|
|||
|
|
@ -62,8 +62,8 @@ export const Suggestion = ({
|
|||
<Button
|
||||
className={cn(
|
||||
"cursor-pointer rounded-full px-[20px] py-[15px] text-sm font-normal",
|
||||
"border-none bg-ws-surface-subtle text-ws-text-muted",
|
||||
"hover:bg-ws-surface-elevated hover:text-ws-base-1",
|
||||
"border-none bg-ws-f9f8fa text-ws-667085",
|
||||
"hover:bg-ws-fbfafc hover:text-ws-150033",
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ function ScrollArea({
|
|||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
|
|
|
|||
|
|
@ -309,7 +309,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
|||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-ws-surface-base",
|
||||
"relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -430,7 +430,7 @@ export function ArtifactFileDetail({
|
|||
type="single"
|
||||
variant={null}
|
||||
size="default"
|
||||
className="bg-ws-surface-base h-[28px]"
|
||||
className="h-[28px] bg-ws-ffffff"
|
||||
value={viewMode}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
|
|
@ -721,7 +721,7 @@ export function ArtifactFileDetail({
|
|||
</ArtifactHeader>
|
||||
<ArtifactContent>
|
||||
{/* 遮挡多余的滚动顶部 */}
|
||||
{/* <div className="absolute w-[calc(100%-40px)] bg-ws-surface-base z-20 h-5 rounded-t-[10px] top-[57px]"></div> */}
|
||||
{/* <div className="absolute w-[calc(100%-40px)] bg-ws-ffffff z-20 h-5 rounded-t-[10px] top-[57px]"></div> */}
|
||||
{previewable &&
|
||||
viewMode === "preview" &&
|
||||
(language === "markdown" || language === "html") && (
|
||||
|
|
@ -734,7 +734,7 @@ export function ArtifactFileDetail({
|
|||
/>
|
||||
)}
|
||||
{isCodeFile && viewMode === "code" && (
|
||||
<div className="bg-ws-surface-base mb-0 mb-[207px] min-h-full rounded-b-[10px] p-0">
|
||||
<div className="mb-0 mb-[207px] min-h-full rounded-b-[10px] bg-ws-ffffff p-0">
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none py-[20px]"
|
||||
value={displayContent ?? ""}
|
||||
|
|
@ -917,7 +917,7 @@ export function ArtifactFilePreview({
|
|||
if (language === "markdown") {
|
||||
return (
|
||||
<div
|
||||
className={cn("bg-ws-surface-base mb-[207px] w-full p-[20px]")}
|
||||
className={cn("mb-[207px] w-full bg-ws-ffffff p-[20px]")}
|
||||
style={{ "--zoom-scale": zoomScale } as CSSProperties}
|
||||
>
|
||||
<Streamdown
|
||||
|
|
@ -974,7 +974,7 @@ function PreviewIframe({
|
|||
{...props}
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="bg-ws-surface-base/85 absolute inset-0 z-10 flex items-center justify-center">
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-ws-ffffff/85">
|
||||
<LoaderIcon className="text-muted-foreground size-5 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1046,7 +1046,7 @@ function ArtifactPdfPreview({
|
|||
|
||||
const pageWrapper = document.createElement("div");
|
||||
pageWrapper.className =
|
||||
"mx-auto mb-4 w-fit rounded-md border border-ws-line-default bg-ws-surface-base p-2 shadow-sm";
|
||||
"mx-auto mb-4 w-fit rounded-md border border-ws-e4e7ec bg-ws-ffffff p-2 shadow-sm";
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.style.width = `${viewport.width}px`;
|
||||
|
|
@ -1089,13 +1089,8 @@ function ArtifactPdfPreview({
|
|||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-ws-surface-subtle relative overflow-auto p-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="border-ws-line-default bg-ws-surface-base mx-auto grid max-w-xl gap-3 rounded-md border p-5 text-center">
|
||||
<div className={cn("relative overflow-auto bg-ws-f9f8fa p-4", className)}>
|
||||
<div className="mx-auto grid max-w-xl gap-3 rounded-md border border-ws-e4e7ec bg-ws-ffffff p-5 text-center">
|
||||
<p className="text-sm font-medium break-all">{fileName}</p>
|
||||
<p className="text-muted-foreground text-sm">{error}</p>
|
||||
<a
|
||||
|
|
@ -1112,20 +1107,15 @@ function ArtifactPdfPreview({
|
|||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-ws-surface-subtle relative overflow-auto p-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="text-ws-text-muted mb-3 text-center text-xs">
|
||||
<div className={cn("relative overflow-auto bg-ws-f9f8fa p-4", className)}>
|
||||
<div className="mb-3 text-center text-xs text-ws-667085">
|
||||
{pageCount > 0
|
||||
? t.artifactPreview.pageCountLabel(fileName, pageCount)
|
||||
: fileName}
|
||||
</div>
|
||||
<div ref={containerRef} />
|
||||
{isLoading && (
|
||||
<div className="bg-ws-surface-base/70 absolute inset-0 z-10 flex items-center justify-center">
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-ws-ffffff/70">
|
||||
<LoaderIcon className="text-muted-foreground size-5 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1323,12 +1313,7 @@ function ArtifactOfficePreview({
|
|||
}, [canRenderPptx, t.artifactPreview.pptxDownloadHint]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-ws-surface-base relative h-full overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn("relative h-full overflow-hidden bg-ws-ffffff", className)}>
|
||||
{canRenderXlsx && sheetNames.length > 0 && (
|
||||
<div className="border-border flex items-center gap-1 overflow-x-auto border-b p-2">
|
||||
{sheetNames.map((sheetName) => (
|
||||
|
|
@ -1338,7 +1323,7 @@ function ArtifactOfficePreview({
|
|||
className={cn(
|
||||
"rounded px-4 py-3 text-xs whitespace-nowrap",
|
||||
activeSheet === sheetName
|
||||
? "bg-ws-accent-tint-soft text-foreground"
|
||||
? "bg-ws-1500331a text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setActiveSheet(sheetName)}
|
||||
|
|
@ -1372,7 +1357,7 @@ function ArtifactOfficePreview({
|
|||
/>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="bg-ws-surface-base/85 absolute inset-0 z-10 flex items-center justify-center">
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-ws-ffffff/85">
|
||||
<LoaderIcon className="text-muted-foreground size-5 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1391,7 +1376,7 @@ function ArtifactPreviewFallback({
|
|||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="bg-ws-surface-base absolute inset-0 z-20 grid place-content-center p-6 text-center">
|
||||
<div className="absolute inset-0 z-20 grid place-content-center bg-ws-ffffff p-6 text-center">
|
||||
<p className="text-foreground mb-2 text-sm font-medium">{fileName}</p>
|
||||
<p className="text-muted-foreground mb-3 text-xs">{message}</p>
|
||||
<a
|
||||
|
|
@ -1415,23 +1400,9 @@ function rewriteArtifactImagePaths(
|
|||
return content;
|
||||
}
|
||||
|
||||
const encodeVirtualPath = (path: string) =>
|
||||
path
|
||||
.split("/")
|
||||
.map((segment) => {
|
||||
try {
|
||||
return encodeURIComponent(decodeURIComponent(segment));
|
||||
} catch {
|
||||
return encodeURIComponent(segment);
|
||||
}
|
||||
})
|
||||
.join("/");
|
||||
const toArtifactUrl = (rawPath: string) => {
|
||||
const trimmedPath = rawPath.trim();
|
||||
const normalizedPath = trimmedPath.startsWith("/")
|
||||
? trimmedPath
|
||||
: `/${trimmedPath}`;
|
||||
return resolveArtifactURL(encodeVirtualPath(normalizedPath), threadId);
|
||||
const normalizedPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
|
||||
return resolveArtifactURL(normalizedPath, threadId);
|
||||
};
|
||||
const toArtifactUrlFromRelative = (rawPath: string) => {
|
||||
const trimmed = rawPath.trim();
|
||||
|
|
@ -1445,17 +1416,17 @@ function rewriteArtifactImagePaths(
|
|||
|
||||
const absolutePath = new URL(trimmed, `file://${baseDir}`).pathname;
|
||||
if (!absolutePath.startsWith("/mnt/user-data/")) return null;
|
||||
return resolveArtifactURL(encodeVirtualPath(absolutePath), threadId);
|
||||
return resolveArtifactURL(absolutePath, threadId);
|
||||
};
|
||||
|
||||
const markdownRewritten = content.replace(
|
||||
/!\[([^\]]*)\]\(\s*(\/?mnt\/user-data\/(?:outputs|uploads)\/[^)]+?)\s*\)/g,
|
||||
/!\[([^\]]*)\]\(\s*(\/?mnt\/user-data\/outputs\/[^)\s]+)\s*\)/g,
|
||||
(_full, alt, rawPath) => {
|
||||
return `})`;
|
||||
},
|
||||
);
|
||||
const markdownRelativeRewritten = markdownRewritten.replace(
|
||||
/!\[([^\]]*)\]\(\s*([^)]+?)\s*\)/g,
|
||||
/!\[([^\]]*)\]\(\s*([^) \t]+)\s*\)/g,
|
||||
(_full, alt, rawPath) => {
|
||||
const absoluteUrl = toArtifactUrlFromRelative(rawPath);
|
||||
if (!absoluteUrl) {
|
||||
|
|
@ -1466,7 +1437,7 @@ function rewriteArtifactImagePaths(
|
|||
);
|
||||
|
||||
const shorthandMarkdownRewritten = markdownRelativeRewritten.replace(
|
||||
/!(?!\[)([^\n()()]+?)\s*[((]\s*(\/?mnt\/user-data\/(?:outputs|uploads)\/[^))]+?)\s*[))]/g,
|
||||
/!(?!\[)([^\n()()]+?)\s*[((]\s*(\/?mnt\/user-data\/outputs\/[^)\s)]+)\s*[))]/g,
|
||||
(_full, alt, rawPath) => {
|
||||
return `})`;
|
||||
},
|
||||
|
|
@ -1475,7 +1446,7 @@ function rewriteArtifactImagePaths(
|
|||
return shorthandMarkdownRewritten.replace(
|
||||
/(<img\b[^>]*\bsrc\s*=\s*)(["'])([^"']+)\2/gi,
|
||||
(_full, prefix, quote, rawPath) => {
|
||||
if (/^\/?mnt\/user-data\/(?:outputs|uploads)\//.test(rawPath)) {
|
||||
if (/^\/?mnt\/user-data\/outputs\//.test(rawPath)) {
|
||||
return `${prefix}${quote}${toArtifactUrl(rawPath)}${quote}`;
|
||||
}
|
||||
const absoluteUrl = toArtifactUrlFromRelative(rawPath);
|
||||
|
|
@ -1588,34 +1559,34 @@ function buildArtifactViewerSrcDoc({
|
|||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<style>
|
||||
:root {
|
||||
--ws-color-surface-app: rgb(248 249 251);
|
||||
--ws-color-surface-base: rgb(255 255 255);
|
||||
--ws-color-text-primary-strong: rgb(15 23 42);
|
||||
--ws-color-text-muted: rgb(102 112 133);
|
||||
--ws-color-line-default: rgb(228 231 236);
|
||||
--ws-color-surface-checker: rgb(244 244 245);
|
||||
--ws-color-black-solid: rgb(0 0 0);
|
||||
--ws-color-info-primary: rgb(37 99 235);
|
||||
--bg: var(--ws-color-surface-app);
|
||||
--panel: var(--ws-color-surface-base);
|
||||
--text: var(--ws-color-text-primary-strong);
|
||||
--muted: var(--ws-color-text-muted);
|
||||
--line: var(--ws-color-line-default);
|
||||
--checker: var(--ws-color-surface-checker);
|
||||
--media-bg: var(--ws-color-black-solid);
|
||||
--link: var(--ws-color-info-primary);
|
||||
--ws-color-f8f9fb: rgb(248 249 251);
|
||||
--ws-color-ffffff: rgb(255 255 255);
|
||||
--ws-color-0f172a: rgb(15 23 42);
|
||||
--ws-color-667085: rgb(102 112 133);
|
||||
--ws-color-e4e7ec: rgb(228 231 236);
|
||||
--ws-color-f4f4f5: rgb(244 244 245);
|
||||
--ws-color-000000: rgb(0 0 0);
|
||||
--ws-color-2563eb: rgb(37 99 235);
|
||||
--bg: var(--ws-color-f8f9fb);
|
||||
--panel: var(--ws-color-ffffff);
|
||||
--text: var(--ws-color-0f172a);
|
||||
--muted: var(--ws-color-667085);
|
||||
--line: var(--ws-color-e4e7ec);
|
||||
--checker: var(--ws-color-f4f4f5);
|
||||
--media-bg: var(--ws-color-000000);
|
||||
--link: var(--ws-color-2563eb);
|
||||
--radius: 12px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--ws-color-surface-app: rgb(32 36 44);
|
||||
--ws-color-surface-base: rgb(42 39 49);
|
||||
--ws-color-text-primary-strong: rgb(230 234 242);
|
||||
--ws-color-text-muted: rgb(152 162 179);
|
||||
--ws-color-line-default: rgb(58 61 69);
|
||||
--ws-color-surface-checker: rgb(44 47 56);
|
||||
--ws-color-black-solid: rgb(0 0 0);
|
||||
--ws-color-info-primary: rgb(127 178 255);
|
||||
--ws-color-f8f9fb: rgb(32 36 44);
|
||||
--ws-color-ffffff: rgb(42 39 49);
|
||||
--ws-color-0f172a: rgb(230 234 242);
|
||||
--ws-color-667085: rgb(152 162 179);
|
||||
--ws-color-e4e7ec: rgb(58 61 69);
|
||||
--ws-color-f4f4f5: rgb(44 47 56);
|
||||
--ws-color-000000: rgb(0 0 0);
|
||||
--ws-color-2563eb: rgb(127 178 255);
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
|
|
@ -1765,12 +1736,7 @@ export const ArtifactZoomSelector = ({
|
|||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
cx="7.55558"
|
||||
cy="7.55534"
|
||||
r="6.16667"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
<circle cx="7.55558" cy="7.55534" r="6.16667" stroke="currentColor" />
|
||||
<path
|
||||
d="M13.8688 15.4646C14.064 15.6598 14.3806 15.6598 14.5759 15.4646C14.7711 15.2693 14.7711 14.9527 14.5759 14.7574L14.2223 15.111L13.8688 15.4646ZM14.2223 15.111L14.5759 14.7574L11.9092 12.0908L11.5557 12.4443L11.2021 12.7979L13.8688 15.4646L14.2223 15.111Z"
|
||||
fill="currentColor"
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export function ArtifactFileList({
|
|||
<div className="absolute top-5 left-4">
|
||||
{getFileIcon(
|
||||
file,
|
||||
"size-9 stroke-1 text-ws-fg-primary stroke-current",
|
||||
"size-9 stroke-1 text-ws-333333 stroke-current",
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="pl-10 text-xs">
|
||||
|
|
@ -137,7 +137,7 @@ export function ArtifactFileList({
|
|||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-muted-foreground h-full! hover:bg-transparent! hover:text-ws-fg-primary!"
|
||||
className="text-muted-foreground h-full! hover:bg-transparent! hover:text-ws-333333!"
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
{t.common.download}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import type { GroupImperativeHandle } from "react-resizable-panels";
|
|||
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { sanitizeArtifactPaths } from "@/core/artifacts/utils";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
|
|
@ -44,10 +43,6 @@ const ChatBox: React.FC<{
|
|||
deselect,
|
||||
selectedArtifact,
|
||||
} = useArtifacts();
|
||||
const sanitizedArtifacts = useMemo(
|
||||
() => sanitizeArtifactPaths(thread.values.artifacts),
|
||||
[thread.values.artifacts],
|
||||
);
|
||||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
useEffect(() => {
|
||||
|
|
@ -57,7 +52,7 @@ const ChatBox: React.FC<{
|
|||
}
|
||||
|
||||
// Update artifacts from the current thread
|
||||
setArtifacts(sanitizedArtifacts);
|
||||
setArtifacts(thread.values.artifacts);
|
||||
|
||||
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
|
||||
// if (
|
||||
|
|
@ -71,19 +66,19 @@ const ChatBox: React.FC<{
|
|||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||
autoSelectFirstArtifact
|
||||
) {
|
||||
if (sanitizedArtifacts.length > 0) {
|
||||
if (thread?.values?.artifacts?.length > 0) {
|
||||
setAutoSelectFirstArtifact(false);
|
||||
selectArtifact(sanitizedArtifacts[0]!);
|
||||
selectArtifact(thread.values.artifacts[0]!);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
threadId,
|
||||
autoSelectFirstArtifact,
|
||||
deselect,
|
||||
sanitizedArtifacts,
|
||||
selectArtifact,
|
||||
selectedArtifact,
|
||||
setArtifacts,
|
||||
thread.values.artifacts,
|
||||
]);
|
||||
|
||||
const artifactPanelOpen = useMemo(() => {
|
||||
|
|
@ -156,7 +151,7 @@ const ChatBox: React.FC<{
|
|||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{sanitizedArtifacts.length === 0 ? (
|
||||
{thread.values.artifacts?.length === 0 ? (
|
||||
<ConversationEmptyState
|
||||
icon={<FilesIcon />}
|
||||
title={t.chatPage.noArtifactSelectedTitle}
|
||||
|
|
@ -172,7 +167,7 @@ const ChatBox: React.FC<{
|
|||
<main className="min-h-0 grow">
|
||||
<ArtifactFileList
|
||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||
files={sanitizedArtifacts}
|
||||
files={thread.values.artifacts ?? []}
|
||||
threadId={threadId ?? ""}
|
||||
/>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export function DevTodoList({
|
|||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className={cn(
|
||||
"z-[100] rounded-[20px] bg-ws-surface-base p-5 shadow-[0_0_20px_0_rgba(0,0,0,0.20)]",
|
||||
"z-[100] rounded-[20px] bg-ws-ffffff p-5 shadow-[0_0_20px_0_rgba(0,0,0,0.20)]",
|
||||
className,
|
||||
)}
|
||||
align="start"
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ export function IframeTestPanel() {
|
|||
<div
|
||||
ref={panelRef}
|
||||
className={cn(
|
||||
"fixed z-[9999] w-72 rounded-xl border border-violet-200 bg-ws-surface-base/95 shadow-2xl backdrop-blur-sm",
|
||||
"fixed z-[9999] w-72 rounded-xl border border-violet-200 bg-ws-ffffff/95 shadow-2xl backdrop-blur-sm",
|
||||
position ? "top-0 left-0" : "bottom-24 left-3",
|
||||
)}
|
||||
style={position ? { left: position.x, top: position.y } : undefined}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tag } from "@/components/ui/tag";
|
||||
import { useReferenceFiles } from "@/core/artifacts/references";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { SelectedSkillPayloadItem } from "@/core/i18n/locales/types";
|
||||
|
|
@ -81,6 +80,7 @@ import {
|
|||
MENTION_REFERENCE_EVENT,
|
||||
type MentionReferenceEventDetail,
|
||||
} from "@/core/threads/reference-events";
|
||||
import { useUploadedFiles } from "@/core/uploads/hooks";
|
||||
import { useIframeSkill } from "@/hooks/use-iframe-skill";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -96,6 +96,7 @@ import {
|
|||
import { Suggestion, Suggestions } from "../ai-elements/suggestion";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
|
||||
import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
|
|
@ -148,7 +149,7 @@ function WorkspaceToolButton({
|
|||
return (
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
"group h-full rounded-[10px] p-[10px]! hover:bg-ws-surface-subtle hover:text-ws-interactive-primary",
|
||||
"group h-full rounded-[10px] p-[10px]! hover:bg-ws-f9f8fa hover:text-ws-8e47f0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -259,6 +260,7 @@ export function InputBox({
|
|||
}),
|
||||
[t],
|
||||
);
|
||||
const { thread } = useThread();
|
||||
const searchParams = useSearchParams();
|
||||
const iframeSkill = useIframeSkill({ threadId: threadIdFromProps });
|
||||
const isInputDisabled = (disabled ?? false) || iframeSkill.isBootstrapping;
|
||||
|
|
@ -292,7 +294,7 @@ export function InputBox({
|
|||
} | null>(null);
|
||||
const [isInputToolsTourOpen, setIsInputToolsTourOpen] = useState(false);
|
||||
const [isInputToolsTourReady, setIsInputToolsTourReady] = useState(false);
|
||||
const { data: referenceFilesData } = useReferenceFiles(threadIdFromProps);
|
||||
const { data: uploadedFilesData } = useUploadedFiles(threadIdFromProps);
|
||||
|
||||
// isNewThread 时禁用收缩,始终保持展开(除非已提交消息)
|
||||
const effectiveIsFocused =
|
||||
|
|
@ -437,41 +439,49 @@ export function InputBox({
|
|||
);
|
||||
|
||||
const mentionCandidates = useMemo<MentionCandidate[]>(() => {
|
||||
const deduped = new Map<string, MentionCandidate>();
|
||||
(referenceFilesData?.files ?? []).forEach((file) => {
|
||||
const path = file.virtual_path || "";
|
||||
const filename = file.filename ?? path.split("/").pop() ?? path;
|
||||
const refSource = file.source === "upload" ? "upload" : "artifact";
|
||||
const typeLabel =
|
||||
refSource === "upload"
|
||||
? referenceSourceLabels.upload
|
||||
: referenceSourceLabels.artifact;
|
||||
const previewUrl =
|
||||
file.artifact_url ||
|
||||
(threadId
|
||||
const artifactCandidates = (thread.values.artifacts ?? []).map((path) => {
|
||||
const filename = path.split("/").pop() ?? path;
|
||||
return {
|
||||
key: `artifact:${path}`,
|
||||
filename,
|
||||
path,
|
||||
pathTail: getPathTail(path),
|
||||
ref_source: "artifact" as const,
|
||||
ref_kind: "mention" as const,
|
||||
typeLabel: referenceSourceLabels.artifact,
|
||||
isImage: isImageFilename(filename),
|
||||
previewUrl: threadId
|
||||
? urlOfArtifact({
|
||||
filepath: path,
|
||||
threadId,
|
||||
})
|
||||
: undefined);
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
deduped.set(`${refSource}:${path || filename}`, {
|
||||
key: `${refSource}:${path || filename}`,
|
||||
filename,
|
||||
path,
|
||||
pathTail: getPathTail(path),
|
||||
ref_source: refSource,
|
||||
ref_kind: "mention",
|
||||
typeLabel,
|
||||
isImage: isImageFilename(filename),
|
||||
previewUrl,
|
||||
});
|
||||
const uploadCandidates =
|
||||
uploadedFilesData?.files.map((file) => ({
|
||||
key: `upload:${file.virtual_path || file.filename}`,
|
||||
filename: file.filename,
|
||||
path: file.virtual_path,
|
||||
pathTail: getPathTail(file.virtual_path),
|
||||
ref_source: "upload" as const,
|
||||
ref_kind: "mention" as const,
|
||||
typeLabel: referenceSourceLabels.upload,
|
||||
isImage: isImageFilename(file.filename),
|
||||
previewUrl: file.artifact_url,
|
||||
})) ?? [];
|
||||
|
||||
const deduped = new Map<string, MentionCandidate>();
|
||||
[...artifactCandidates, ...uploadCandidates].forEach((candidate) => {
|
||||
deduped.set(candidate.key, candidate);
|
||||
});
|
||||
return [...deduped.values()];
|
||||
}, [
|
||||
referenceFilesData?.files,
|
||||
referenceSourceLabels.artifact,
|
||||
referenceSourceLabels.upload,
|
||||
thread.values.artifacts,
|
||||
uploadedFilesData?.files,
|
||||
threadId,
|
||||
]);
|
||||
|
||||
|
|
@ -879,12 +889,12 @@ export function InputBox({
|
|||
textareaRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 text-sm text-ws-fg-primary">
|
||||
<DropdownMenuLabel className="p-0 text-sm text-ws-333333">
|
||||
{t.inputBox.addReference}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="mx-0 mt-[20px] mb-0" />
|
||||
<DropdownMenuGroup className="flex min-h-0 flex-col gap-[10px] px-0">
|
||||
<ScrollArea className="h-[320px] pt-[20px]" hideScrollbar={false}>
|
||||
<DropdownMenuGroup className="flex max-h-[480px] flex-col gap-[10px] px-0 pt-[20px]">
|
||||
<ScrollArea className="h-[480px]" data-state="hidden">
|
||||
{filteredMentionCandidates.map((candidate, index) => {
|
||||
const detail = [candidate.typeLabel, candidate.pathTail]
|
||||
.filter(Boolean)
|
||||
|
|
@ -970,14 +980,6 @@ export function InputBox({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{!showWelcomeStyle && (
|
||||
<div className="shrink-0 h-full">
|
||||
<ExitChattingButton
|
||||
router={router}
|
||||
threadId={threadIdFromProps}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div ref={attachmentsButtonTourRef} className="shrink-0 h-full">
|
||||
<AddAttachmentsButton />
|
||||
</div>
|
||||
|
|
@ -1232,7 +1234,7 @@ function AddAttachmentsButton({ className }: { className?: string }) {
|
|||
return (
|
||||
<Tooltip content={t.inputBox.addAttachments}>
|
||||
<WorkspaceToolButton
|
||||
className={cn("text-ws-base-1 hover:text-ws-interactive-primary", className)}
|
||||
className={cn("text-ws-150033 hover:text-ws-8e47f0", className)}
|
||||
onClick={() => attachments.openFileDialog()}
|
||||
>
|
||||
<svg
|
||||
|
|
@ -1270,7 +1272,7 @@ function HistoryButton({
|
|||
return (
|
||||
<Tooltip content={t.inputBox.history}>
|
||||
<WorkspaceToolButton
|
||||
className={cn("text-ws-base-1 hover:text-ws-interactive-primary", className)}
|
||||
className={cn("text-ws-150033 hover:text-ws-8e47f0", className)}
|
||||
onClick={() =>
|
||||
router.replace(`/workspace/chats/${threadId}?is_chatting=true`)
|
||||
}
|
||||
|
|
@ -1300,53 +1302,6 @@ function HistoryButton({
|
|||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function ExitChattingButton({
|
||||
className,
|
||||
router,
|
||||
threadId,
|
||||
}: {
|
||||
className?: string;
|
||||
router: AppRouterInstance;
|
||||
threadId: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Tooltip content={t.inputBox.welcome}>
|
||||
<WorkspaceToolButton
|
||||
className={cn(
|
||||
"text-ws-base-1 hover:text-ws-interactive-primary",
|
||||
className,
|
||||
)}
|
||||
onClick={() =>
|
||||
router.replace(`/workspace/chats/${threadId}?is_chatting=false`)
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="transition-[color] duration-200"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
className="stroke-current transition-[stroke] duration-200"
|
||||
cx="9"
|
||||
cy="9"
|
||||
r="8.5"
|
||||
/>
|
||||
<path
|
||||
className="stroke-current transition-[stroke] duration-200"
|
||||
d="M6 9H12"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</WorkspaceToolButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
// 启动iframeSkillDialog
|
||||
function IframeSkillDialogButton({
|
||||
className,
|
||||
|
|
@ -1375,7 +1330,7 @@ function IframeSkillDialogButton({
|
|||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="size-4 text-ws-base-1 transition-[color] duration-200 group-hover:text-ws-interactive-primary"
|
||||
className="size-4 text-ws-150033 transition-[color] duration-200 group-hover:text-ws-8e47f0"
|
||||
viewBox="0 0 12 16"
|
||||
fill="none"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { CheckIcon, CopyIcon, DownloadIcon } from "lucide-react";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { useCallback, useMemo, useState, type MouseEvent } from "react";
|
||||
import type {
|
||||
AnchorHTMLAttributes,
|
||||
|
|
@ -56,57 +56,27 @@ function toMarkdownTable(data: TableData): string {
|
|||
return [headerLine, dividerLine, ...rowLines].join("\n");
|
||||
}
|
||||
|
||||
function escapeCsvCell(value: string): string {
|
||||
if (!/[",\n\r]/.test(value)) return value;
|
||||
return `"${value.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function toCsvTable(data: TableData): string {
|
||||
if (data.headers.length === 0) return "";
|
||||
return [data.headers, ...data.rows]
|
||||
.map((row) => row.map(escapeCsvCell).join(","))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function downloadCsvFile(content: string, filename: string) {
|
||||
const blob = new Blob(["\uFEFF", content], {
|
||||
type: "text/csv;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function MarkdownTable({
|
||||
className,
|
||||
children,
|
||||
isLoading,
|
||||
copyLabel,
|
||||
downloadLabel,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<"table"> & {
|
||||
isLoading: boolean;
|
||||
copyLabel: string;
|
||||
downloadLabel: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const getTableData = useCallback((event: MouseEvent<HTMLButtonElement>) => {
|
||||
const wrapper = event.currentTarget.closest(
|
||||
'[data-streamdown="table-wrapper"]',
|
||||
);
|
||||
const table = wrapper?.querySelector("table");
|
||||
if (!(table instanceof HTMLTableElement)) return null;
|
||||
return parseTableData(table);
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
async (event: MouseEvent<HTMLButtonElement>) => {
|
||||
const data = getTableData(event);
|
||||
if (!data) return;
|
||||
const wrapper = event.currentTarget.closest(
|
||||
'[data-streamdown="table-wrapper"]',
|
||||
);
|
||||
const table = wrapper?.querySelector("table");
|
||||
if (!(table instanceof HTMLTableElement)) return;
|
||||
|
||||
const markdown = toMarkdownTable(data);
|
||||
const markdown = toMarkdownTable(parseTableData(table));
|
||||
if (!markdown) return;
|
||||
|
||||
try {
|
||||
|
|
@ -117,20 +87,7 @@ function MarkdownTable({
|
|||
// no-op
|
||||
}
|
||||
},
|
||||
[getTableData],
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>) => {
|
||||
const data = getTableData(event);
|
||||
if (!data) return;
|
||||
|
||||
const csv = toCsvTable(data);
|
||||
if (!csv) return;
|
||||
|
||||
downloadCsvFile(csv, "table.csv");
|
||||
},
|
||||
[getTableData],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -140,21 +97,14 @@ function MarkdownTable({
|
|||
>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all"
|
||||
className="text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isLoading}
|
||||
onClick={handleCopy}
|
||||
title={copyLabel}
|
||||
type="button"
|
||||
>
|
||||
{copied ? <CheckIcon size={14} /> : <CopyIcon size={14} />}
|
||||
</button>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all"
|
||||
onClick={handleDownload}
|
||||
title={downloadLabel}
|
||||
type="button"
|
||||
>
|
||||
<DownloadIcon size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table
|
||||
|
|
@ -215,7 +165,7 @@ export function MarkdownContent({
|
|||
<MarkdownTable
|
||||
className={className}
|
||||
copyLabel={t.clipboard.copyToClipboard}
|
||||
downloadLabel={t.common.download}
|
||||
isLoading={isLoading}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -223,12 +173,7 @@ export function MarkdownContent({
|
|||
),
|
||||
...componentsFromProps,
|
||||
};
|
||||
}, [
|
||||
componentsFromProps,
|
||||
isLoading,
|
||||
t.clipboard.copyToClipboard,
|
||||
t.common.download,
|
||||
]);
|
||||
}, [componentsFromProps, isLoading, t.clipboard.copyToClipboard]);
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export function MessageGroup({
|
|||
);
|
||||
return (
|
||||
<ChainOfThought
|
||||
className={cn("w-full gap-2 rounded-lg bg-ws-surface-base", className)}
|
||||
className={cn("w-full gap-2 rounded-lg bg-ws-ffffff", className)}
|
||||
open={true}
|
||||
>
|
||||
{aboveLastToolCallSteps.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -27,11 +27,8 @@ import {
|
|||
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
extractSummaryTemplateBody,
|
||||
extractContentFromMessage,
|
||||
normalizeHumanMessageDisplayText,
|
||||
extractReasoningContentFromMessage,
|
||||
isSummaryTemplateMessage,
|
||||
parseUploadedFiles,
|
||||
stripPriorityHintSuffix,
|
||||
stripUploadedFilesTag,
|
||||
|
|
@ -39,6 +36,7 @@ import {
|
|||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { materializeSkillYaml } from "@/core/skills";
|
||||
import { humanMessagePlugins } from "@/core/streamdown";
|
||||
import { dispatchMentionReference } from "@/core/threads/reference-events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -141,7 +139,6 @@ function MessageContent_({
|
|||
isLoading?: boolean;
|
||||
threadId: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
const isHuman = message.type === "human";
|
||||
const components = useMemo(
|
||||
|
|
@ -170,23 +167,12 @@ function MessageContent_({
|
|||
|
||||
const contentToDisplay = useMemo(() => {
|
||||
if (isHuman) {
|
||||
if (!rawContent) {
|
||||
return "";
|
||||
}
|
||||
const cleaned = stripPriorityHintSuffix(stripUploadedFilesTag(rawContent));
|
||||
return normalizeHumanMessageDisplayText(cleaned);
|
||||
return rawContent
|
||||
? stripPriorityHintSuffix(stripUploadedFilesTag(rawContent))
|
||||
: "";
|
||||
}
|
||||
return rawContent ?? "";
|
||||
}, [rawContent, isHuman]);
|
||||
const isSummaryMessage = useMemo(
|
||||
() => isHuman && isSummaryTemplateMessage(message),
|
||||
[isHuman, message],
|
||||
);
|
||||
const summaryBody = useMemo(
|
||||
() => (isSummaryMessage ? extractSummaryTemplateBody(message) : ""),
|
||||
[isSummaryMessage, message],
|
||||
);
|
||||
const [isSummaryExpanded, setIsSummaryExpanded] = useState(false);
|
||||
|
||||
const filesList =
|
||||
files && files.length > 0 && threadId ? (
|
||||
|
|
@ -222,42 +208,20 @@ function MessageContent_({
|
|||
}
|
||||
|
||||
if (isHuman) {
|
||||
const shouldRenderSummaryCollapse = isSummaryMessage && summaryBody;
|
||||
const messageResponse = contentToDisplay ? (
|
||||
<div className="whitespace-break-spaces break-words">
|
||||
<AIElementMessageResponse
|
||||
remarkPlugins={humanMessagePlugins.remarkPlugins}
|
||||
rehypePlugins={humanMessagePlugins.rehypePlugins}
|
||||
components={components}
|
||||
>
|
||||
{contentToDisplay}
|
||||
</div>
|
||||
</AIElementMessageResponse>
|
||||
) : null;
|
||||
return (
|
||||
<div className={cn("ml-auto flex flex-col gap-2", className)}>
|
||||
{filesList}
|
||||
{shouldRenderSummaryCollapse && (
|
||||
<details
|
||||
className="w-fit max-w-full rounded-lg border"
|
||||
open={isSummaryExpanded}
|
||||
onToggle={(event) => {
|
||||
setIsSummaryExpanded(event.currentTarget.open);
|
||||
}}
|
||||
>
|
||||
<summary className="text-muted-foreground cursor-pointer px-3 py-2 text-xs select-none">
|
||||
{isSummaryExpanded
|
||||
? t.toolCalls.collapseContent
|
||||
: t.toolCalls.expandContent}
|
||||
</summary>
|
||||
<AIElementMessageContent className="w-fit border-t">
|
||||
<div className="whitespace-break-spaces break-words">
|
||||
{summaryBody}
|
||||
</div>
|
||||
</AIElementMessageContent>
|
||||
</details>
|
||||
)}
|
||||
{messageResponse && (
|
||||
<AIElementMessageContent
|
||||
className={cn(
|
||||
"w-fit",
|
||||
shouldRenderSummaryCollapse ? "hidden" : undefined,
|
||||
)}
|
||||
>
|
||||
<AIElementMessageContent className="w-fit">
|
||||
{messageResponse}
|
||||
</AIElementMessageContent>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ export function MessageList({
|
|||
{showScrollToBottomButton && (
|
||||
<ConversationScrollButton
|
||||
className={cn(
|
||||
"z-20 rounded-full border bg-ws-surface-base/90 shadow-sm backdrop-blur-sm",
|
||||
"z-20 rounded-full border bg-ws-ffffff/90 shadow-sm backdrop-blur-sm",
|
||||
scrollButtonClassName,
|
||||
)}
|
||||
title={t.chats.scrollToBottom}
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ function ThemePreviewCard({
|
|||
"relative overflow-hidden rounded-md border text-xs transition-colors",
|
||||
previewMode === "dark"
|
||||
? "border-neutral-800 bg-neutral-900 text-neutral-200"
|
||||
: "border-slate-200 bg-ws-surface-base text-slate-900",
|
||||
: "border-slate-200 bg-ws-ffffff text-slate-900",
|
||||
)}
|
||||
>
|
||||
<div className="border-border/50 flex items-center gap-2 border-b px-3 py-2">
|
||||
|
|
|
|||
|
|
@ -14,19 +14,19 @@ export function StreamingIndicator({
|
|||
<div
|
||||
className={cn(
|
||||
dotSize,
|
||||
"animate-bouncing rounded-full bg-ws-icon-muted opacity-100",
|
||||
"animate-bouncing rounded-full bg-ws-a3a1a1 opacity-100",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
dotSize,
|
||||
"animate-bouncing rounded-full bg-ws-icon-muted opacity-100 [animation-delay:0.2s]",
|
||||
"animate-bouncing rounded-full bg-ws-a3a1a1 opacity-100 [animation-delay:0.2s]",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
dotSize,
|
||||
"animate-bouncing rounded-full bg-ws-icon-muted opacity-100 [animation-delay:0.4s]",
|
||||
"animate-bouncing rounded-full bg-ws-a3a1a1 opacity-100 [animation-delay:0.4s]",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export function TodoList({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-fit w-full origin-bottom translate-y-4 flex-col overflow-hidden rounded-t-xl border border-b-0 bg-ws-surface-base backdrop-blur-sm transition-all duration-200 ease-out",
|
||||
"flex h-fit w-full origin-bottom translate-y-4 flex-col overflow-hidden rounded-t-xl border border-b-0 bg-ws-ffffff backdrop-blur-sm transition-all duration-200 ease-out",
|
||||
hidden ? "pointer-events-none translate-y-8 opacity-0" : "",
|
||||
className,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export function WorkspaceHeader({ className }: { className?: string }) {
|
|||
) : (
|
||||
<div className="text-primary ml-2 cursor-default font-serif">
|
||||
{/* TODO: 测试标识 */}
|
||||
XClaw <span className="text-sm text-ws-text-subtle-strong">v3.2.8</span>
|
||||
XClaw <span className="text-sm text-ws-000000c5">v3.2.8</span>
|
||||
</div>
|
||||
)}
|
||||
<SidebarTrigger />
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { getBackendBaseURL } from "../config";
|
||||
|
||||
export type ReferenceFileInfo = {
|
||||
filename: string;
|
||||
size: string;
|
||||
virtual_path: string;
|
||||
artifact_url: string;
|
||||
source: "artifact" | "upload";
|
||||
};
|
||||
|
||||
type ListReferenceFilesResponse = {
|
||||
files: ReferenceFileInfo[];
|
||||
count: number;
|
||||
};
|
||||
|
||||
async function listReferenceFiles(
|
||||
threadId: string,
|
||||
): Promise<ListReferenceFilesResponse> {
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/threads/${threadId}/artifacts/list`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to list reference files");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function useReferenceFiles(threadId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["references", "list", threadId],
|
||||
queryFn: () => listReferenceFiles(threadId ?? ""),
|
||||
enabled: Boolean(threadId),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
import { getBackendBaseURL } from "../config";
|
||||
import type { AgentThread } from "../threads";
|
||||
|
||||
const ARTIFACTS_REPLACE_SENTINEL = "__deerflow_replace_artifacts__";
|
||||
|
||||
export function urlOfArtifact({
|
||||
filepath,
|
||||
threadId,
|
||||
|
|
@ -21,13 +19,9 @@ export function urlOfArtifact({
|
|||
}
|
||||
|
||||
export function extractArtifactsFromThread(thread: AgentThread) {
|
||||
return sanitizeArtifactPaths(thread.values.artifacts);
|
||||
return thread.values.artifacts ?? [];
|
||||
}
|
||||
|
||||
export function resolveArtifactURL(absolutePath: string, threadId: string) {
|
||||
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${absolutePath}`;
|
||||
}
|
||||
|
||||
export function sanitizeArtifactPaths(paths: string[] | undefined | null) {
|
||||
return (paths ?? []).filter((path) => path !== ARTIFACTS_REPLACE_SENTINEL);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,6 @@ export const enUS: Translations = {
|
|||
"Please note, this feature will consume tokens. Ensure your account balance is greater than 200 credits.",
|
||||
addAttachments: "Add attachments",
|
||||
history: "History",
|
||||
welcome:"Welcome",
|
||||
selectSkill: "Select Skill",
|
||||
mode: "Mode",
|
||||
flashMode: "Flash",
|
||||
|
|
|
|||
|
|
@ -75,7 +75,6 @@ export interface Translations {
|
|||
createSkillPrompt: string;
|
||||
addAttachments: string;
|
||||
history: string;
|
||||
welcome:string;
|
||||
selectSkill: string;
|
||||
mode: string;
|
||||
flashMode: string;
|
||||
|
|
|
|||
|
|
@ -87,7 +87,6 @@ export const zhCN: Translations = {
|
|||
"请注意,此功能将消耗token,请保证账户余额大于200可学豆。",
|
||||
addAttachments: "添加附件",
|
||||
history: "历史记录",
|
||||
welcome:"欢迎页",
|
||||
selectSkill: "选择Skill",
|
||||
mode: "模式",
|
||||
flashMode: "闪速",
|
||||
|
|
@ -135,13 +134,13 @@ export const zhCN: Translations = {
|
|||
suggestion: "GPT-Image-2",
|
||||
prompt: "编写[项目/功能]的需求文档,包含功能描述、用户故事和验收标准。",
|
||||
icon: CompassIcon,
|
||||
children: [{ id: "6130", name: "GPT-Image-2" }],
|
||||
children: [{ id: "6107", name: "GPT-Image-2" }],
|
||||
},
|
||||
{
|
||||
suggestion: "音乐生成",
|
||||
prompt: "编写[产品/功能]的使用指南,包含操作步骤、注意事项和常见问题。",
|
||||
icon: GraduationCapIcon,
|
||||
children: [{ id: "6133", name: "音乐生成器" }],
|
||||
children: [{ id: "6126", name: "旋律制造机" }],
|
||||
},
|
||||
{
|
||||
suggestion: "excel数据处理",
|
||||
|
|
@ -263,7 +262,7 @@ export const zhCN: Translations = {
|
|||
noArtifactSelectedDescription: "请选择一个生成文件以查看详情",
|
||||
exitDialogTitle: "提示",
|
||||
exitDialogDescription:
|
||||
"每七天自动删除。现在将返回欢迎页且清空聊天消息,是否继续?",
|
||||
"历史记录每七天自动删除,现在将返回欢迎页,是否继续?",
|
||||
exitDialogConfirm: "确定",
|
||||
selectedSkillLoadFailed: "技能加载失败",
|
||||
unknownErrorRetry: "发生了未知错误,请稍后重试。",
|
||||
|
|
|
|||
|
|
@ -26,47 +26,6 @@ type MessageGroup =
|
|||
| AssistantClarificationGroup
|
||||
| AssistantSubagentGroup;
|
||||
|
||||
const SUMMARY_MESSAGE_TITLES = [
|
||||
"Here is a summary of the conversation to date",
|
||||
"以下是目前对话的摘要",
|
||||
];
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function getSummaryTemplateTitle(content: string) {
|
||||
return (
|
||||
SUMMARY_MESSAGE_TITLES.find((title) => {
|
||||
const titlePattern = new RegExp(
|
||||
`^\\s*${escapeRegExp(title)}\\s*[::]?(?:\\n|$)`,
|
||||
"i",
|
||||
);
|
||||
return titlePattern.test(content);
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function isSummaryTemplateMessage(message: Message) {
|
||||
if (message.type !== "human") {
|
||||
return false;
|
||||
}
|
||||
return getSummaryTemplateTitle(extractTextFromMessage(message)) !== null;
|
||||
}
|
||||
|
||||
export function extractSummaryTemplateBody(message: Message) {
|
||||
const content = extractTextFromMessage(message);
|
||||
const title = getSummaryTemplateTitle(content);
|
||||
if (!title) {
|
||||
return content;
|
||||
}
|
||||
const titlePrefixPattern = new RegExp(
|
||||
`^\\s*${escapeRegExp(title)}\\s*[::]?\\s*\\n*`,
|
||||
"i",
|
||||
);
|
||||
return content.replace(titlePrefixPattern, "").trim();
|
||||
}
|
||||
|
||||
export function groupMessages<T>(
|
||||
messages: Message[],
|
||||
mapper: (group: MessageGroup) => T,
|
||||
|
|
@ -98,9 +57,6 @@ export function groupMessages<T>(
|
|||
}
|
||||
|
||||
if (message.type === "human") {
|
||||
// if (isSummaryTemplateMessage(message)) {
|
||||
// continue;
|
||||
// }
|
||||
groups.push({ id: message.id, type: "human", messages: [message] });
|
||||
continue;
|
||||
}
|
||||
|
|
@ -408,17 +364,6 @@ export function stripPriorityHintSuffix(content: string): string {
|
|||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize human-authored message text for markdown rendering.
|
||||
* - Decode literal "\n" into real line breaks.
|
||||
* - Split Chinese-numbered items (e.g. "1)...") into separate paragraphs.
|
||||
*/
|
||||
export function normalizeHumanMessageDisplayText(content: string): string {
|
||||
// Preserve human input as-is for display; only decode escaped newlines
|
||||
// and normalize CRLF/CR to LF so line breaks render consistently.
|
||||
return content.replace(/\\n/g, "\n").replace(/\r\n?/g, "\n");
|
||||
}
|
||||
|
||||
export function parseUploadedFiles(content: string): FileInMessage[] {
|
||||
// Match <uploaded_files>...</uploaded_files> tag
|
||||
const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/;
|
||||
|
|
|
|||
|
|
@ -201,24 +201,24 @@
|
|||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-tooltip-background: var(--tooltip-background);
|
||||
--color-ws-base-1: var(--ws-color-base-1);
|
||||
--color-ws-fg-primary: var(--ws-color-fg-primary);
|
||||
--color-ws-surface-subtle: var(--ws-color-surface-subtle);
|
||||
--color-ws-surface-elevated: var(--ws-color-surface-elevated);
|
||||
--color-ws-interactive-primary: var(--ws-color-interactive-primary);
|
||||
--color-ws-line-default: var(--ws-color-line-default);
|
||||
--color-ws-text-muted: var(--ws-color-text-muted);
|
||||
--color-ws-icon-muted: var(--ws-color-icon-muted);
|
||||
--color-ws-overlay-neutral: var(--ws-color-overlay-neutral);
|
||||
--color-ws-text-subtle-strong: var(--ws-color-text-subtle-strong);
|
||||
--color-ws-border-hairline: var(--ws-color-border-hairline);
|
||||
--color-ws-accent-tint-soft: var(--ws-color-accent-tint-soft);
|
||||
--color-ws-surface-app: var(--ws-color-surface-app);
|
||||
--color-ws-surface-base: var(--ws-color-surface-base);
|
||||
--color-ws-text-primary-strong: var(--ws-color-text-primary-strong);
|
||||
--color-ws-surface-checker: var(--ws-color-surface-checker);
|
||||
--color-ws-black-solid: var(--ws-color-black-solid);
|
||||
--color-ws-info-primary: var(--ws-color-info-primary);
|
||||
--color-ws-150033: var(--ws-color-150033);
|
||||
--color-ws-333333: var(--ws-color-333333);
|
||||
--color-ws-f9f8fa: var(--ws-color-f9f8fa);
|
||||
--color-ws-fbfafc: var(--ws-color-fbfafc);
|
||||
--color-ws-8e47f0: var(--ws-color-8e47f0);
|
||||
--color-ws-e4e7ec: var(--ws-color-e4e7ec);
|
||||
--color-ws-667085: var(--ws-color-667085);
|
||||
--color-ws-a3a1a1: var(--ws-color-a3a1a1);
|
||||
--color-ws-999999: var(--ws-color-999999);
|
||||
--color-ws-000000c5: var(--ws-color-000000c5);
|
||||
--color-ws-00000015: var(--ws-color-00000015);
|
||||
--color-ws-1500331a: var(--ws-color-1500331a);
|
||||
--color-ws-f8f9fb: var(--ws-color-f8f9fb);
|
||||
--color-ws-ffffff: var(--ws-color-ffffff);
|
||||
--color-ws-0f172a: var(--ws-color-0f172a);
|
||||
--color-ws-f4f4f5: var(--ws-color-f4f4f5);
|
||||
--color-ws-000000: var(--ws-color-000000);
|
||||
--color-ws-2563eb: var(--ws-color-2563eb);
|
||||
--animate-aurora: aurora 8s ease-in-out infinite alternate;
|
||||
|
||||
@keyframes aurora {
|
||||
|
|
@ -307,24 +307,24 @@
|
|||
--sidebar-border: oklch(0.922 0.0098 87.47);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--tooltip-background: #00000066;
|
||||
--ws-color-base-1: #150033;
|
||||
--ws-color-fg-primary: #333333;
|
||||
--ws-color-surface-subtle: #f9f8fa;
|
||||
--ws-color-surface-elevated: #fbfafc;
|
||||
--ws-color-interactive-primary: #8e47f0;
|
||||
--ws-color-line-default: #e4e7ec;
|
||||
--ws-color-text-muted: #667085;
|
||||
--ws-color-icon-muted: #a3a1a1;
|
||||
--ws-color-overlay-neutral: #999999;
|
||||
--ws-color-text-subtle-strong: #000000c5;
|
||||
--ws-color-border-hairline: #00000015;
|
||||
--ws-color-accent-tint-soft: #1500331a;
|
||||
--ws-color-surface-app: #f8f9fb;
|
||||
--ws-color-surface-base: #ffffff;
|
||||
--ws-color-text-primary-strong: #0f172a;
|
||||
--ws-color-surface-checker: #f4f4f5;
|
||||
--ws-color-black-solid: #000000;
|
||||
--ws-color-info-primary: #2563eb;
|
||||
--ws-color-150033: #150033;
|
||||
--ws-color-333333: #333333;
|
||||
--ws-color-f9f8fa: #f9f8fa;
|
||||
--ws-color-fbfafc: #fbfafc;
|
||||
--ws-color-8e47f0: #8e47f0;
|
||||
--ws-color-e4e7ec: #e4e7ec;
|
||||
--ws-color-667085: #667085;
|
||||
--ws-color-a3a1a1: #a3a1a1;
|
||||
--ws-color-999999: #999999;
|
||||
--ws-color-000000c5: #000000c5;
|
||||
--ws-color-00000015: #00000015;
|
||||
--ws-color-1500331a: #1500331a;
|
||||
--ws-color-f8f9fb: #f8f9fb;
|
||||
--ws-color-ffffff: #ffffff;
|
||||
--ws-color-0f172a: #0f172a;
|
||||
--ws-color-f4f4f5: #f4f4f5;
|
||||
--ws-color-000000: #000000;
|
||||
--ws-color-2563eb: #2563eb;
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
|
@ -360,24 +360,24 @@
|
|||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--tooltip-background: oklch(0.85 0 0);
|
||||
--ws-color-base-1: #f4ebff;
|
||||
--ws-color-fg-primary: #f5f5f5;
|
||||
--ws-color-surface-subtle: #1f1f1f;
|
||||
--ws-color-surface-elevated: #24222a;
|
||||
--ws-color-interactive-primary: #b987ff;
|
||||
--ws-color-line-default: #3b3f48;
|
||||
--ws-color-text-muted: #98a2b3;
|
||||
--ws-color-icon-muted: #d0d0d0;
|
||||
--ws-color-overlay-neutral: #c2c2c2;
|
||||
--ws-color-text-subtle-strong: #ffffffcc;
|
||||
--ws-color-border-hairline: #ffffff1f;
|
||||
--ws-color-accent-tint-soft: #f4ebff24;
|
||||
--ws-color-surface-app: #20242c;
|
||||
--ws-color-surface-base: #2a2731;
|
||||
--ws-color-text-primary-strong: #e6eaf2;
|
||||
--ws-color-surface-checker: #2c2f38;
|
||||
--ws-color-black-solid: #000000;
|
||||
--ws-color-info-primary: #7fb2ff;
|
||||
--ws-color-150033: #f4ebff;
|
||||
--ws-color-333333: #f5f5f5;
|
||||
--ws-color-f9f8fa: #1f1f1f;
|
||||
--ws-color-fbfafc: #24222a;
|
||||
--ws-color-8e47f0: #b987ff;
|
||||
--ws-color-e4e7ec: #3b3f48;
|
||||
--ws-color-667085: #98a2b3;
|
||||
--ws-color-a3a1a1: #d0d0d0;
|
||||
--ws-color-999999: #c2c2c2;
|
||||
--ws-color-000000c5: #ffffffcc;
|
||||
--ws-color-00000015: #ffffff1f;
|
||||
--ws-color-1500331a: #f4ebff24;
|
||||
--ws-color-f8f9fb: #20242c;
|
||||
--ws-color-ffffff: #2a2731;
|
||||
--ws-color-0f172a: #e6eaf2;
|
||||
--ws-color-f4f4f5: #2c2f38;
|
||||
--ws-color-000000: #000000;
|
||||
--ws-color-2563eb: #7fb2ff;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,36 +1,25 @@
|
|||
/**
|
||||
* Workspace 语义化颜色 Token(单一事实来源)。
|
||||
*
|
||||
* 用法说明:
|
||||
* 1) Token 键会作为 UI 类名中的颜色标识使用,例如 `bg-ws-surface-base`。
|
||||
* 2) 必须在 `src/styles/globals.css` 中存在对应 CSS 变量:
|
||||
* - `:root` 和 `.dark` 下都要有 `--ws-color-<token-suffix>`
|
||||
* - `@theme inline` 下要有 `--color-ws-<token-suffix>`
|
||||
* 3) `scripts/color-guard.mjs` 会导入本文件,并校验上述覆盖关系。
|
||||
*/
|
||||
export type WorkspaceColorToken = {
|
||||
light: `#${string}`;
|
||||
dark: `#${string}`;
|
||||
};
|
||||
|
||||
// Token 键保持语义化且稳定:`ws-<role>-<level>`(不要再使用原始 hex 命名)。
|
||||
export const WORKSPACE_COLOR_TOKENS = {
|
||||
"ws-base-1": { light: "#150033", dark: "#f4ebff" },
|
||||
"ws-fg-primary": { light: "#333333", dark: "#f5f5f5" },
|
||||
"ws-surface-subtle": { light: "#f9f8fa", dark: "#1f1f1f" },
|
||||
"ws-surface-elevated": { light: "#fbfafc", dark: "#24222a" },
|
||||
"ws-interactive-primary": { light: "#8e47f0", dark: "#b987ff" },
|
||||
"ws-line-default": { light: "#e4e7ec", dark: "#3b3f48" },
|
||||
"ws-text-muted": { light: "#667085", dark: "#98a2b3" },
|
||||
"ws-icon-muted": { light: "#a3a1a1", dark: "#d0d0d0" },
|
||||
"ws-overlay-neutral": { light: "#999999", dark: "#c2c2c2" },
|
||||
"ws-text-subtle-strong": { light: "#000000c5", dark: "#ffffffcc" },
|
||||
"ws-border-hairline": { light: "#00000015", dark: "#ffffff1f" },
|
||||
"ws-accent-tint-soft": { light: "#1500331a", dark: "#f4ebff24" },
|
||||
"ws-surface-app": { light: "#f8f9fb", dark: "#20242c" },
|
||||
"ws-surface-base": { light: "#ffffff", dark: "#2a2731" },
|
||||
"ws-text-primary-strong": { light: "#0f172a", dark: "#e6eaf2" },
|
||||
"ws-surface-checker": { light: "#f4f4f5", dark: "#2c2f38" },
|
||||
"ws-black-solid": { light: "#000000", dark: "#000000" },
|
||||
"ws-info-primary": { light: "#2563eb", dark: "#7fb2ff" },
|
||||
"ws-150033": { light: "#150033", dark: "#f4ebff" },
|
||||
"ws-333333": { light: "#333333", dark: "#f5f5f5" },
|
||||
"ws-f9f8fa": { light: "#f9f8fa", dark: "#1f1f1f" },
|
||||
"ws-fbfafc": { light: "#fbfafc", dark: "#24222a" },
|
||||
"ws-8e47f0": { light: "#8e47f0", dark: "#b987ff" },
|
||||
"ws-e4e7ec": { light: "#e4e7ec", dark: "#3b3f48" },
|
||||
"ws-667085": { light: "#667085", dark: "#98a2b3" },
|
||||
"ws-a3a1a1": { light: "#a3a1a1", dark: "#d0d0d0" },
|
||||
"ws-999999": { light: "#999999", dark: "#c2c2c2" },
|
||||
"ws-000000c5": { light: "#000000c5", dark: "#ffffffcc" },
|
||||
"ws-00000015": { light: "#00000015", dark: "#ffffff1f" },
|
||||
"ws-1500331a": { light: "#1500331a", dark: "#f4ebff24" },
|
||||
"ws-f8f9fb": { light: "#f8f9fb", dark: "#20242c" },
|
||||
"ws-ffffff": { light: "#ffffff", dark: "#2a2731" },
|
||||
"ws-0f172a": { light: "#0f172a", dark: "#e6eaf2" },
|
||||
"ws-f4f4f5": { light: "#f4f4f5", dark: "#2c2f38" },
|
||||
"ws-000000": { light: "#000000", dark: "#000000" },
|
||||
"ws-2563eb": { light: "#2563eb", dark: "#7fb2ff" },
|
||||
} as const satisfies Record<string, WorkspaceColorToken>;
|
||||
|
|
|
|||
Loading…
Reference in New Issue