Compare commits

..

3 Commits

48 changed files with 332 additions and 1445 deletions

View File

@ -10,7 +10,6 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, PlainTextResponse, Response from fastapi.responses import FileResponse, PlainTextResponse, Response
from app.gateway.path_utils import resolve_thread_virtual_path from app.gateway.path_utils import resolve_thread_virtual_path
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths
logger = logging.getLogger(__name__) 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 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: def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool:
"""Check if file is text by examining content for null bytes.""" """Check if file is text by examining content for null bytes."""
try: try:
@ -139,38 +106,6 @@ def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> byte
return None 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( @router.get(
"/threads/{thread_id}/artifacts/{path:path}", "/threads/{thread_id}/artifacts/{path:path}",
summary="Get Artifact File", summary="Get Artifact File",

View File

@ -67,13 +67,11 @@ async def proxy_request(provider: str, path: str, request: Request) -> Response:
path=path, path=path,
request=request, request=request,
body=body, body=body,
request_json=request_json,
thread_id=thread_id, thread_id=thread_id,
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
task_id_jsonpath=submit_route.task_id_jsonpath, task_id_jsonpath=submit_route.task_id_jsonpath,
route_frozen_amount=submit_route.frozen_amount, route_frozen_amount=submit_route.frozen_amount,
route_frozen_type=submit_route.frozen_type, route_frozen_type=submit_route.frozen_type,
route_frozen_token=submit_route.frozen_token,
) )
if query_route: if query_route:
@ -111,13 +109,11 @@ async def _handle_submit(
path: str, path: str,
request: Request, request: Request,
body: bytes, body: bytes,
request_json: dict[str, Any] | None,
thread_id: str | None, thread_id: str | None,
idempotency_key: str | None, idempotency_key: str | None,
task_id_jsonpath: str, task_id_jsonpath: str,
route_frozen_amount: float | None, route_frozen_amount: float | None,
route_frozen_type: int | None, route_frozen_type: int | None,
route_frozen_token: int | None,
) -> Response: ) -> Response:
ledger = get_ledger() ledger = get_ledger()
@ -133,7 +129,6 @@ async def _handle_submit(
# Reserve billing before touching the provider # 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_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_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( frozen_id = await billing.reserve(
thread_id=thread_id, thread_id=thread_id,
call_id=record.call_id, call_id=record.call_id,
@ -141,11 +136,9 @@ async def _handle_submit(
operation=path, operation=path,
frozen_amount=reserve_frozen_amount, frozen_amount=reserve_frozen_amount,
frozen_type=reserve_frozen_type, frozen_type=reserve_frozen_type,
frozen_token=reserve_frozen_token,
request_payload=request_json,
) )
if frozen_id: 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 # Forward to provider
try: try:
@ -163,32 +156,6 @@ async def _handle_submit(
resp_json = _try_parse_json(resp_body) 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 # HTTP-level failure
if status_code >= 400: if status_code >= 400:
reason = f"error_http_{status_code}" reason = f"error_http_{status_code}"
@ -305,31 +272,18 @@ async def _handle_query(
"[ThirdPartyProxy] finalize claimed: proxy_call_id=%s", "[ThirdPartyProxy] finalize claimed: proxy_call_id=%s",
record.proxy_call_id, 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 final_amount: float = 0.0
if is_success: if is_success and query_route.usage_jsonpath:
if resolved_frozen_type == 1: raw_amount = proxy.jsonpath_get(resp_json, query_route.usage_jsonpath)
usage_input_tokens, usage_output_tokens = _extract_usage_tokens(resp_json) try:
else: final_amount = float(raw_amount) if raw_amount is not None else 0.0
final_amount = _resolve_final_amount(resp_json, query_route) except (TypeError, ValueError):
final_amount = 0.0
logger.debug( 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, record.proxy_call_id,
resolved_frozen_type,
final_amount, final_amount,
usage_input_tokens,
usage_output_tokens,
usage_paths,
query_route.usage_jsonpath, query_route.usage_jsonpath,
) )
@ -349,8 +303,6 @@ async def _handle_query(
frozen_id=record.frozen_id, frozen_id=record.frozen_id,
final_amount=final_amount, final_amount=final_amount,
finalize_reason=finalize_reason, finalize_reason=finalize_reason,
usage_input_tokens=usage_input_tokens,
usage_output_tokens=usage_output_tokens,
) )
logger.info( logger.info(
"[ThirdPartyProxy] finalize result: proxy_call_id=%s ok=%s", "[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 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( def _proxy_response(
data: dict[str, Any], data: dict[str, Any],
proxy_call_id: str | None, proxy_call_id: str | None,

View File

@ -10,7 +10,6 @@ from __future__ import annotations
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any
import httpx import httpx
@ -29,8 +28,6 @@ async def reserve(
operation: str, operation: str,
frozen_amount: float, frozen_amount: float,
frozen_type: int | None, frozen_type: int | None,
frozen_token: int = 0,
request_payload: dict[str, Any] | None = None,
) -> str | None: ) -> str | None:
"""Reserve billing before forwarding a submit call. """Reserve billing before forwarding a submit call.
@ -47,25 +44,19 @@ async def reserve(
) )
return None 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) expire_at = datetime.now() + timedelta(seconds=cfg.default_expire_seconds)
payload: dict[str, Any] = { payload = {
"sessionId": thread_id, "sessionId": thread_id,
"callId": call_id, "callId": call_id,
"modelName": _extract_model_name(request_payload) or provider, "modelName": provider,
"question": f"skill invokes {operation.split('/')[-1]}", "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"), "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( logger.info(
"[ThirdPartyProxy][Billing] reserve request: url=%s call_id=%s provider=%s thread_id=%s", "[ThirdPartyProxy][Billing] reserve request: url=%s call_id=%s provider=%s thread_id=%s",
cfg.reserve_url, cfg.reserve_url,
@ -123,8 +114,6 @@ async def finalize(
frozen_id: str, frozen_id: str,
final_amount: float, final_amount: float,
finalize_reason: str, finalize_reason: str,
usage_input_tokens: int = 0,
usage_output_tokens: int = 0,
) -> bool: ) -> bool:
"""Finalize billing after a third-party call reaches a terminal state. """Finalize billing after a third-party call reaches a terminal state.
@ -146,9 +135,9 @@ async def finalize(
payload = { payload = {
"frozenId": frozen_id, "frozenId": frozen_id,
"finalAmount": final_amount, "finalAmount": final_amount,
"usageInputTokens": usage_input_tokens, "usageInputTokens": 0,
"usageOutputTokens": usage_output_tokens, "usageOutputTokens": 0,
"usageTotalTokens": usage_input_tokens + usage_output_tokens, "usageTotalTokens": 0,
"finalizeReason": finalize_reason, "finalizeReason": finalize_reason,
} }
@ -199,12 +188,3 @@ def _is_success(data: dict) -> bool:
if isinstance(status, int) and status in _SUCCESS_STATUS_CODES: if isinstance(status, int) and status in _SUCCESS_STATUS_CODES:
return True return True
return data.get("success") is 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

View File

@ -27,7 +27,6 @@ class CallRecord:
# call_id is sent to the billing platform (callId in reserve payload) # call_id is sent to the billing platform (callId in reserve payload)
call_id: str call_id: str
frozen_id: str | None = None frozen_id: str | None = None
frozen_type: int | None = None
provider_task_id: str | None = None provider_task_id: str | None = None
billing_state: BillingState = "UNRESERVED" billing_state: BillingState = "UNRESERVED"
task_state: TaskState = "PENDING" task_state: TaskState = "PENDING"
@ -110,18 +109,16 @@ class CallLedger:
def get_by_idempotency_key(self, provider: str, idempotency_key: str) -> CallRecord | None: def get_by_idempotency_key(self, provider: str, idempotency_key: str) -> CallRecord | None:
return self._get_by_idem_key_locked(provider, idempotency_key) 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: with self._lock:
record = self._records.get(proxy_call_id) record = self._records.get(proxy_call_id)
if record: if record:
record.frozen_id = frozen_id record.frozen_id = frozen_id
record.frozen_type = frozen_type
record.billing_state = "RESERVED" record.billing_state = "RESERVED"
logger.info( 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, proxy_call_id,
frozen_id, frozen_id,
frozen_type,
) )
# logger.debug( # logger.debug(
# "[ThirdPartyProxy][Ledger] reserve state: call_id=%s provider=%s task_state=%s", # "[ThirdPartyProxy][Ledger] reserve state: call_id=%s provider=%s task_state=%s",

View File

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import os import os
from typing import Any from typing import Any
@ -18,7 +17,16 @@ from deerflow.config.third_party_proxy_config import (
logger = logging.getLogger(__name__) 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 # 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: def _preview_body(data: bytes, limit: int = 2048) -> str:
"""Return a safe textual preview of body bytes for debugging logs.""" """Return a safe textual preview of body bytes for debugging logs."""
if not data: if not data:
@ -157,53 +176,6 @@ def _preview_body(data: bytes, limit: int = 2048) -> str:
return text 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( async def forward_request(
*, *,
provider_config: ThirdPartyProviderConfig, provider_config: ThirdPartyProviderConfig,
@ -230,9 +202,6 @@ async def forward_request(
if provider_config.api_key_env: if provider_config.api_key_env:
api_key = os.getenv(provider_config.api_key_env) api_key = os.getenv(provider_config.api_key_env)
if api_key: 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 forward_headers[provider_config.api_key_header] = provider_config.api_key_prefix + api_key
else: else:
logger.warning( logger.warning(
@ -243,7 +212,7 @@ async def forward_request(
logger.info("[ThirdPartyProxy] → %s %s", method, target_url) logger.info("[ThirdPartyProxy] → %s %s", method, target_url)
logger.debug( logger.debug(
"[ThirdPartyProxy] request headers=%s", "[ThirdPartyProxy] request headers=%s",
forward_headers, _sanitize_headers(forward_headers)
) )
logger.debug( logger.debug(
"[ThirdPartyProxy] request body(%dB)=%s", "[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.info("[ThirdPartyProxy] ← %s %s %d", method, target_url, response.status_code)
logger.debug( logger.debug(
"[ThirdPartyProxy] response headers=%s", "[ThirdPartyProxy] response headers=%s",
response_headers, _sanitize_headers(response_headers)
) )
logger.debug( logger.debug(
"[ThirdPartyProxy] response body(%dB)=%s", "[ThirdPartyProxy] response body(%dB)=%s",

View File

@ -2,12 +2,10 @@ import logging
from langchain.agents import create_agent from langchain.agents import create_agent
from langchain.agents.middleware import AgentMiddleware, SummarizationMiddleware from langchain.agents.middleware import AgentMiddleware, SummarizationMiddleware
from langchain_core.messages.human import HumanMessage
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from deerflow.agents.lead_agent.prompt import apply_prompt_template from deerflow.agents.lead_agent.prompt import apply_prompt_template
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware 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.loop_detection_middleware import LoopDetectionMiddleware
from deerflow.agents.middlewares.message_timestamp_middleware import MessageTimestampMiddleware from deerflow.agents.middlewares.message_timestamp_middleware import MessageTimestampMiddleware
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
@ -25,15 +23,6 @@ from deerflow.models import create_chat_model
logger = logging.getLogger(__name__) 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: 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.""" """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: if config.summary_prompt is not None:
kwargs["summary_prompt"] = config.summary_prompt kwargs["summary_prompt"] = config.summary_prompt
return DeerFlowSummarizationMiddleware(**kwargs) return SummarizationMiddleware(**kwargs)
def _create_todo_list_middleware(is_plan_mode: bool) -> TodoMiddleware | None: 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: if get_app_config().token_usage.enabled:
middlewares.append(TokenUsageMiddleware()) middlewares.append(TokenUsageMiddleware())
# Reconcile stale artifact entries against real outputs files.
middlewares.append(ArtifactReconcileMiddleware())
# Stamp every conversation message with backend timestamp metadata. # Stamp every conversation message with backend timestamp metadata.
middlewares.append(MessageTimestampMiddleware()) middlewares.append(MessageTimestampMiddleware())

View File

@ -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]}

View File

@ -174,7 +174,6 @@ def _extract_run_id(request: ModelRequest) -> str | None: # noqa: ARG001
def _reserve_failure_message(status_code: int | None) -> str: def _reserve_failure_message(status_code: int | None) -> str:
if status_code in _blocking_reserve_code_set(): if status_code in _blocking_reserve_code_set():
# TODO: 将账单错误文案迁移到国际化资源中,按语言返回提示。
return "The account balance is insufficient for this model call." return "The account balance is insufficient for this model call."
return "Billing reservation failed. Please try again later." return "Billing reservation failed. Please try again later."

View File

@ -1,6 +1,5 @@
"""Middleware for intercepting clarification requests and presenting them to the user.""" """Middleware for intercepting clarification requests and presenting them to the user."""
import json
import logging import logging
from collections.abc import Callable from collections.abc import Callable
from typing import override from typing import override
@ -36,28 +35,6 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
state_schema = 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: def _is_chinese(self, text: str) -> bool:
"""Check if text contains Chinese characters. """Check if text contains Chinese characters.
@ -81,7 +58,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
question = args.get("question", "") question = args.get("question", "")
clarification_type = args.get("clarification_type", "missing_info") clarification_type = args.get("clarification_type", "missing_info")
context = args.get("context") context = args.get("context")
options = self._normalize_options(args.get("options")) options = args.get("options", [])
# Type-specific icons # Type-specific icons
type_icons = { type_icons = {
@ -107,7 +84,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
message_parts.append(f"{icon} {question}") message_parts.append(f"{icon} {question}")
# Add options in a cleaner format # Add options in a cleaner format
if options: if options and len(options) > 0:
message_parts.append("") # blank line for spacing message_parts.append("") # blank line for spacing
for i, option in enumerate(options, 1): for i, option in enumerate(options, 1):
message_parts.append(f" {i}. {option}") message_parts.append(f" {i}. {option}")

View File

@ -2,8 +2,6 @@ from typing import Annotated, NotRequired, TypedDict
from langchain.agents import AgentState from langchain.agents import AgentState
ARTIFACTS_REPLACE_SENTINEL = "__deerflow_replace_artifacts__"
class SandboxState(TypedDict): class SandboxState(TypedDict):
sandbox_id: NotRequired[str | None] 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]: def merge_artifacts(existing: list[str] | None, new: list[str] | None) -> list[str]:
"""Reducer for artifacts list - merges and deduplicates artifacts.""" """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: if existing is None:
return cleaned_new return new or []
if new is None: if new is None:
return cleaned_existing return existing
# Use dict.fromkeys to deduplicate while preserving order # 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]: def merge_viewed_images(existing: dict[str, ViewedImageData] | None, new: dict[str, ViewedImageData] | None) -> dict[str, ViewedImageData]:

View File

@ -28,11 +28,6 @@ class SubmitRouteConfig(BaseModel):
default=None, default=None,
description="Optional route-level override for billing reserve payload frozenType", 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): class QueryRouteConfig(BaseModel):
@ -61,14 +56,6 @@ class QueryRouteConfig(BaseModel):
"E.g. usage.thirdPartyConsumeMoney" "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): class ThirdPartyProviderConfig(BaseModel):
@ -101,11 +88,6 @@ class ThirdPartyProviderConfig(BaseModel):
default=None, default=None,
description="Billing frozen type for this provider (frozenType). If omitted, falls back to billing.frozen_type", 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( submit_routes: list[SubmitRouteConfig] = Field(
default_factory=list, default_factory=list,
description="Route patterns that identify submit (task-create) requests", description="Route patterns that identify submit (task-create) requests",

View File

@ -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"]
}

View File

@ -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 bytes(response.body).decode("utf-8") == "ok"
assert response.media_type == "text/markdown" 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"

View File

@ -147,8 +147,7 @@ def test_create_summarization_middleware_uses_configured_model_alias(monkeypatch
) )
captured: dict[str, object] = {} captured: dict[str, object] = {}
fake_model = MagicMock() fake_model = object()
fake_model._llm_type = "test-chat"
def _fake_create_chat_model(*, name=None, thinking_enabled, reasoning_effort=None): def _fake_create_chat_model(*, name=None, thinking_enabled, reasoning_effort=None):
captured["name"] = name captured["name"] = name
@ -157,20 +156,10 @@ def test_create_summarization_middleware_uses_configured_model_alias(monkeypatch
return fake_model return fake_model
monkeypatch.setattr(lead_agent_module, "create_chat_model", _fake_create_chat_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() middleware = lead_agent_module._create_summarization_middleware()
assert captured["name"] == "model-masswork" assert captured["name"] == "model-masswork"
assert captured["thinking_enabled"] is False assert captured["thinking_enabled"] is False
assert isinstance(middleware, lead_agent_module.DeerFlowSummarizationMiddleware) assert middleware["model"] is fake_model
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旧上下文"

View File

@ -3,16 +3,8 @@
from __future__ import annotations from __future__ import annotations
from app.gateway.third_party_proxy.ledger import CallLedger 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 ( from app.gateway.third_party_proxy.proxy import (
API_KEY_MARKER,
_path_matches, _path_matches,
_replace_api_key_marker_in_body,
_replace_api_key_marker_in_headers,
jsonpath_get, jsonpath_get,
match_query_route, match_query_route,
match_submit_route, match_submit_route,
@ -107,7 +99,6 @@ _PROVIDER_CFG = ThirdPartyProviderConfig(
success_values=["SUCCESS"], success_values=["SUCCESS"],
failure_values=["FAILED", "CANCELLED"], failure_values=["FAILED", "CANCELLED"],
usage_jsonpath="usage.thirdPartyConsumeMoney", 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"}) ledger.update_response(rec.proxy_call_id, {"result": "ok"})
found = ledger.get(rec.proxy_call_id) found = ledger.get(rec.proxy_call_id)
assert found.last_response == {"result": "ok"} 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

View File

@ -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",
]

View File

@ -56,7 +56,7 @@ billing:
# third-party async task APIs such as RunningHub. # third-party async task APIs such as RunningHub.
third_party_proxy: third_party_proxy:
enabled: true enabled: false
providers: providers:
runninghub: runninghub:
base_url: https://www.runninghub.cn base_url: https://www.runninghub.cn
@ -64,53 +64,35 @@ third_party_proxy:
api_key_header: Authorization api_key_header: Authorization
api_key_prefix: "Bearer " api_key_prefix: "Bearer "
timeout_seconds: 30.0 timeout_seconds: 30.0
frozen_amount: 10.0
frozen_type: 2 frozen_type: 2
submit_routes: 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" task_id_jsonpath: "taskId"
frozen_amount: 0.03 # Optional per-model billing override examples:
frozen_type: 2 # frozen_amount: 10.0
- path_pattern: "/openapi/v2/rhart-image-g-2/text-to-image" # frozen_type: 2
task_id_jsonpath: "taskId"
frozen_amount: 0.2 # Example: model-specific reserve policy
frozen_type: 2 # - path_pattern: "/openapi/v2/rhart-image/z-image/turbo-lora"
- path_pattern: "/openapi/v2/rhart-image-g-2/image-to-image" # task_id_jsonpath: "taskId"
task_id_jsonpath: "taskId" # frozen_amount: 10.0
frozen_amount: 0.2 # frozen_type: 2
frozen_type: 2 # - path_pattern: "/openapi/v2/vidu/text-to-video-q3-turbo"
- path_pattern: "/openapi/v2/rhart-audio/text-to-audio/speech-2.8-turbo" # task_id_jsonpath: "taskId"
task_id_jsonpath: "taskId" # frozen_amount: 50.0
frozen_amount: 1.85 # frozen_type: 2
frozen_type: 2 # - path_pattern: "/openapi/v2/wan-2.7/image-edit"
- path_pattern: "/task/openapi/create" # task_id_jsonpath: "taskId"
task_id_jsonpath: "data.taskId" # frozen_amount: 20.0
frozen_amount: 2.0 # frozen_type: 2
frozen_type: 2
- path_pattern: "/openapi/v2/vidu/text-to-video-q3-turbo"
task_id_jsonpath: "taskId"
frozen_amount: 11.2
frozen_type: 2
query_routes: query_routes:
- path_pattern: "/openapi/v2/query" - path_pattern: "/openapi/v2/query"
request_task_id_jsonpath: "taskId" request_task_id_jsonpath: "taskId"
status_jsonpath: "status" status_jsonpath: "status"
success_values: ["SUCCESS"] success_values: ["SUCCESS"]
failure_values: ["FAILED", "CANCELLED"] failure_values: ["FAILED", "CANCELLED"]
usage_jsonpaths: ["usage.thirdPartyConsumeMoney", "usage.consumeMoney"] usage_jsonpath: "usage.thirdPartyConsumeMoney"
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: []
# ============================================================================ # ============================================================================
# Token Usage Tracking # Token Usage Tracking

View File

@ -35,7 +35,7 @@ The examples below assume Python + requests.
Add: Add:
- `load_skill_env()`: loads skill-local `.env` - `load_skill_env()`: loads skill-local `.env`
- `get_gateway_config()`: reads - `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`) - `RUNNINGHUB_PROXY_PROVIDER` (default `runninghub`)
### Step 2: Centralize proxy headers ### Step 2: Centralize proxy headers
@ -83,25 +83,6 @@ Recommended checks:
- submit fallback when `taskId` is missing - submit fallback when `taskId` is missing
- query loop timeout/failure handling - 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) ## 4. Proxy Config Migration (config.yaml)
Configure submit/query routes under `third_party_proxy.providers.<provider>`. 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. 5. Config extraction fields use shorthand dot-paths only.
6. Submit returns `taskId`, then query reaches `RUNNING/SUCCESS`. 6. Submit returns `taskId`, then query reaches `RUNNING/SUCCESS`.
7. Gateway logs show submit/query route hits and finalize flow. 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 ## 8. Reference Implementations

View File

@ -194,7 +194,7 @@ async function validateTokenRegistry() {
const darkSeen = new Map(); const darkSeen = new Map();
for (const [name, value] of entries) { 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}"`); errors.push(`invalid token name "${name}"`);
} }
const light = String(value.light ?? "").toLowerCase(); const light = String(value.light ?? "").toLowerCase();
@ -234,7 +234,7 @@ function collectWsVarsFromBlocks(css, selectorPattern) {
const selector = block[1]?.trim() ?? ""; const selector = block[1]?.trim() ?? "";
const body = block[2] ?? ""; const body = block[2] ?? "";
if (!selectorPattern.test(selector)) continue; 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]}`); vars.add(`ws-${match[1]}`);
} }
} }
@ -246,7 +246,7 @@ function validateGlobalsCoverage(tokenEntries) {
const rootVars = collectWsVarsFromBlocks(css, /(^|,)\s*:root(\s|,|$)/); const rootVars = collectWsVarsFromBlocks(css, /(^|,)\s*:root(\s|,|$)/);
const darkVars = collectWsVarsFromBlocks(css, /(^|,)\s*\.dark(\s|,|$)/); const darkVars = collectWsVarsFromBlocks(css, /(^|,)\s*\.dark(\s|,|$)/);
const inlineVars = new Set( 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)); const tokenNames = new Set(tokenEntries.map(([name]) => name));

View File

@ -32,7 +32,6 @@ import { Tooltip } from "@/components/workspace/tooltip";
import { useSpecificChatMode } from "@/components/workspace/use-chat-mode"; import { useSpecificChatMode } from "@/components/workspace/use-chat-mode";
import { Welcome } from "@/components/workspace/welcome"; import { Welcome } from "@/components/workspace/welcome";
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
import { sanitizeArtifactPaths } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages"; import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
@ -81,30 +80,23 @@ export default function ChatPage() {
() => isNewThread && !safeThreadId, () => isNewThread && !safeThreadId,
[isNewThread, safeThreadId], [isNewThread, safeThreadId],
); );
const [isThreadInitReady, setIsThreadInitReady] = useState(false);
const streamThreadId = useMemo(() => { const streamThreadId = useMemo(() => {
if (!safeThreadId) { if (isNewThread && createNewSession) {
return undefined;
}
// In /new flow, defer history loading until thread init is finished:
// delete -> create -> history.
if (isNewThread && !isThreadInitReady) {
return undefined; return undefined;
} }
return safeThreadId; return safeThreadId;
}, [isNewThread, isThreadInitReady, safeThreadId]); }, [createNewSession, isNewThread, safeThreadId]);
const apiClient = useMemo(() => getAPIClient(isMock), [isMock]); const apiClient = useMemo(() => getAPIClient(isMock), [isMock]);
const warnedMissingThreadIdRef = useRef(false); const warnedMissingThreadIdRef = useRef(false);
const initializedThreadRef = useRef<string | null>(null); const initializedThreadRef = useRef<string | null>(null);
const threadInitPromiseRef = useRef<Promise<void> | null>(null);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const currentSlogan = motivationSlogans[ const currentSlogan = motivationSlogans[
sloganIndex % motivationSlogans.length sloganIndex % motivationSlogans.length
] ?? { ] ?? {
text: t.chatPage.defaultSlogan, text: t.chatPage.defaultSlogan,
color: "var(--color-ws-fg-primary)", color: "var(--color-ws-333333)",
}; };
const tickerCharacterList = useMemo(() => { const tickerCharacterList = useMemo(() => {
const seen = new Set<string>(); const seen = new Set<string>();
@ -137,7 +129,6 @@ export default function ChatPage() {
useEffect(() => { useEffect(() => {
if (!isNewThread) { if (!isNewThread) {
warnedMissingThreadIdRef.current = false; warnedMissingThreadIdRef.current = false;
setIsThreadInitReady(true);
return; return;
} }
if (!safeThreadId) { if (!safeThreadId) {
@ -145,38 +136,29 @@ export default function ChatPage() {
warnedMissingThreadIdRef.current = true; warnedMissingThreadIdRef.current = true;
toast.error(t.chatPage.missingThreadIdForCreate); toast.error(t.chatPage.missingThreadIdForCreate);
} }
setIsThreadInitReady(false);
return; return;
} }
warnedMissingThreadIdRef.current = false; warnedMissingThreadIdRef.current = false;
if (initializedThreadRef.current === safeThreadId) return; if (initializedThreadRef.current === safeThreadId) return;
initializedThreadRef.current = safeThreadId; initializedThreadRef.current = safeThreadId;
setIsThreadInitReady(false); void apiClient.threads
// TODO: 先注释先删除再创建的逻辑
const initPromise = apiClient.threads // .delete(safeThreadId)
.delete(safeThreadId) // .catch(() => undefined)
.catch(() => undefined) // .then(() =>
.then(() => // apiClient.threads.create({
apiClient.threads.create({ // threadId: safeThreadId,
// ifExists: "raise",
// }),
// )
.create({
threadId: safeThreadId, threadId: safeThreadId,
ifExists: "do_nothing", ifExists: "do_nothing",
}),
)
.then(() => {
setIsThreadInitReady(true);
}) })
.catch(() => { .catch(() => {
initializedThreadRef.current = null; initializedThreadRef.current = null;
setIsThreadInitReady(false);
toast.error(t.chatPage.createSessionFailed); toast.error(t.chatPage.createSessionFailed);
}); });
threadInitPromiseRef.current = initPromise;
void initPromise.finally(() => {
if (threadInitPromiseRef.current === initPromise) {
threadInitPromiseRef.current = null;
}
});
}, [ }, [
apiClient, apiClient,
isNewThread, isNewThread,
@ -228,10 +210,6 @@ export default function ChatPage() {
const result = thread.values?.title ?? ""; const result = thread.values?.title ?? "";
return result === "Untitled" ? "" : result; return result === "Untitled" ? "" : result;
}, [thread.values?.title]); }, [thread.values?.title]);
const sanitizedArtifacts = useMemo(
() => sanitizeArtifactPaths(thread.values.artifacts),
[thread.values.artifacts],
);
const [hasSubmitted, setHasSubmitted] = useState(false); const [hasSubmitted, setHasSubmitted] = useState(false);
const [historyCutoff, setHistoryCutoff] = useState<number | null>(null); const [historyCutoff, setHistoryCutoff] = useState<number | null>(null);
@ -280,21 +258,21 @@ export default function ChatPage() {
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
useEffect(() => { useEffect(() => {
setArtifacts(sanitizedArtifacts); setArtifacts(thread.values.artifacts);
if ( if (
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
autoSelectFirstArtifact autoSelectFirstArtifact
) { ) {
if (sanitizedArtifacts.length > 0) { if (thread?.values?.artifacts?.length > 0) {
setAutoSelectFirstArtifact(false); setAutoSelectFirstArtifact(false);
selectArtifact(sanitizedArtifacts[0]!); selectArtifact(thread.values.artifacts[0]!);
} }
} }
}, [ }, [
autoSelectFirstArtifact, autoSelectFirstArtifact,
sanitizedArtifacts,
selectArtifact, selectArtifact,
setArtifacts, setArtifacts,
thread.values.artifacts,
]); ]);
const artifactPanelOpen = useMemo(() => { const artifactPanelOpen = useMemo(() => {
@ -308,7 +286,7 @@ export default function ChatPage() {
const [showExitDialog, setShowExitDialog] = useState(false); const [showExitDialog, setShowExitDialog] = useState(false);
const isStreaming = isUploading || thread.isLoading; const isStreaming = isUploading || thread.isLoading;
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (message: Parameters<typeof sendMessage>[1]) => { (message: Parameters<typeof sendMessage>[1]) => {
if (isSelectedSkillBootstrapping) { if (isSelectedSkillBootstrapping) {
return; return;
} }
@ -316,12 +294,6 @@ export default function ChatPage() {
toast.error(t.chatPage.missingThreadIdForSend); toast.error(t.chatPage.missingThreadIdForSend);
return; return;
} }
if (isNewThread && safeThreadId) {
await threadInitPromiseRef.current;
}
if (isNewThread && safeThreadId && !isThreadInitReady) {
return;
}
setHasSubmitted(true); setHasSubmitted(true);
if (safeThreadId && (isNewThread || showWelcomeStyle)) { if (safeThreadId && (isNewThread || showWelcomeStyle)) {
router.replace(`/workspace/chats/${safeThreadId}?is_chatting=true`); router.replace(`/workspace/chats/${safeThreadId}?is_chatting=true`);
@ -330,7 +302,6 @@ export default function ChatPage() {
}, },
[ [
isNewThread, isNewThread,
isThreadInitReady,
isSelectedSkillBootstrapping, isSelectedSkillBootstrapping,
router, router,
safeThreadId, safeThreadId,
@ -386,7 +357,7 @@ export default function ChatPage() {
<Button <Button
size="sm" size="sm"
variant="ghost" 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} disabled={isStreaming}
onClick={() => setShowExitDialog(true)} onClick={() => setShowExitDialog(true)}
> >
@ -399,7 +370,7 @@ export default function ChatPage() {
> >
<path <path
d="M3.5 10H13.25H15.6875H16.5M3.5 10L7.5625 6M3.5 10L7.5625 14" 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" stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.5"
strokeLinecap="round" strokeLinecap="round"
@ -409,7 +380,7 @@ export default function ChatPage() {
</Button> </Button>
</div> </div>
<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={{ style={{
color: currentSlogan.color, color: currentSlogan.color,
}} }}
@ -429,7 +400,7 @@ export default function ChatPage() {
<div className="flex items-center justify-end gap-2 overflow-hidden"> <div className="flex items-center justify-end gap-2 overflow-hidden">
{/* 取消TodoList */} {/* 取消TodoList */}
{/* <DevTodoList {/* <DevTodoList
className="bg-ws-surface-base" className="bg-ws-ffffff"
todos={thread.values.todos ?? []} todos={thread.values.todos ?? []}
hidden={ hidden={
!thread.values.todos || thread.values.todos.length === 0 !thread.values.todos || thread.values.todos.length === 0
@ -438,7 +409,7 @@ export default function ChatPage() {
<Button <Button
size="sm" size="sm"
variant="ghost" 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 <ListTodoIcon className="size-4" /> To-dos
</Button> </Button>
@ -449,7 +420,7 @@ export default function ChatPage() {
<Tooltip content={t.chatPage.viewArtifactsTooltip}> <Tooltip content={t.chatPage.viewArtifactsTooltip}>
<Button <Button
data-testid="artifacts-open-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" variant="ghost"
onClick={() => { onClick={() => {
setArtifactsOpen(true); setArtifactsOpen(true);
@ -467,7 +438,7 @@ export default function ChatPage() {
className={cn( className={cn(
"flex min-h-0 max-w-full grow flex-col", "flex min-h-0 max-w-full grow flex-col",
showWelcomeStyle && !hasSubmitted showWelcomeStyle && !hasSubmitted
? "bg-ws-surface-base" ? "bg-ws-ffffff"
: "bg-background", : "bg-background",
)} )}
> >
@ -521,7 +492,7 @@ export default function ChatPage() {
) : ( ) : (
<div className="relative flex size-full justify-center px-[20px]"> <div className="relative flex size-full justify-center px-[20px]">
<div className="z-30"></div> <div className="z-30"></div>
{sanitizedArtifacts.length === 0 ? ( {thread.values.artifacts?.length === 0 ? (
<ConversationEmptyState <ConversationEmptyState
icon={<FilesIcon />} icon={<FilesIcon />}
title={t.chatPage.noArtifactSelectedTitle} 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"> <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"> <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> <span>{t.common.artifacts}</span>
</h2> </h2>
<Button <Button
@ -547,7 +518,7 @@ export default function ChatPage() {
<main className="min-h-0 grow overflow-auto"> <main className="min-h-0 grow overflow-auto">
<ArtifactFileList <ArtifactFileList
className="mb-[207px] max-w-(--container-width-sm) pt-[20px]" className="mb-[207px] max-w-(--container-width-sm) pt-[20px]"
files={sanitizedArtifacts} files={thread.values.artifacts ?? []}
threadId={threadId} threadId={threadId}
/> />
</main> </main>
@ -578,7 +549,7 @@ export default function ChatPage() {
{!(showWelcomeStyle && thread.isThreadLoading) ? ( {!(showWelcomeStyle && thread.isThreadLoading) ? (
<> <>
<InputBox <InputBox
className={cn("w-full rounded-[20px] bg-ws-surface-elevated")} className={cn("w-full rounded-[20px] bg-ws-fbfafc")}
threadId={threadId} threadId={threadId}
showWelcomeStyle={showWelcomeStyle} showWelcomeStyle={showWelcomeStyle}
hasSubmitted={hasSubmitted} hasSubmitted={hasSubmitted}
@ -638,14 +609,14 @@ export default function ChatPage() {
</p> </p>
<DevDialogFooter> <DevDialogFooter>
<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" variant="ghost"
onClick={() => setShowExitDialog(false)} onClick={() => setShowExitDialog(false)}
> >
{t.common.cancel} {t.common.cancel}
</Button> </Button>
<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" variant="ghost"
onClick={async () => { onClick={async () => {
// 如果正在生成,先终止再退出 // 如果正在生成,先终止再退出
@ -657,14 +628,14 @@ export default function ChatPage() {
type: POST_MESSAGE_TYPES.IS_CHATTING, type: POST_MESSAGE_TYPES.IS_CHATTING,
isChatting: false, isChatting: false,
}); });
resetNewSessionState();
// 始终复用 query 中的 thread_id。 // 始终复用 query 中的 thread_id。
const nextQuery = new URLSearchParams(); const nextQuery = new URLSearchParams();
if (threadId && threadId !== "new") { if (threadId && threadId !== "new") {
nextQuery.set("thread_id", threadId); nextQuery.set("thread_id", threadId);
} }
// /workspace/chats/${threadId}?is_chatting=false
router.replace( router.replace(
`/workspace/chats/new?thread_id=${threadId}`, `/workspace/chats/${threadId}?is_chatting=false`,
); );
}} }}
> >
@ -694,7 +665,7 @@ export default function ChatPage() {
</p> </p>
<DevDialogFooter singleColumn> <DevDialogFooter singleColumn>
<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" variant="ghost"
onClick={clearSelectedSkillError} onClick={clearSelectedSkillError}
> >

View File

@ -130,7 +130,7 @@ export default function WorkspaceLayout({
/* 灰色圆角矩形容器 */ /* 灰色圆角矩形容器 */
"rounded-[20px] border-none", "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)]", "shadow-[0_2px_12px_0_rgba(0,0,0,0.18)]",
/* 内边距:宽松居中 */ /* 内边距:宽松居中 */

View File

@ -36,7 +36,7 @@ export const Message = ({
"group flex w-full flex-col gap-2", "group flex w-full flex-col gap-2",
from === "user" from === "user"
? cn("is-user ml-auto justify-end", !isFirstInSession && "mt-6") ? 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, className,
)} )}
{...props} {...props}

View File

@ -352,7 +352,7 @@ export function PromptInputAttachment({
{/* 删除按钮 - 右上角 */} {/* 删除按钮 - 右上角 */}
<button <button
aria-label={t.common.removeAttachment} 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) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (onRemove) { if (onRemove) {
@ -397,7 +397,7 @@ export function PromptInputAttachment({
{/* 关闭按钮 - 右上角 */} {/* 关闭按钮 - 右上角 */}
<button <button
aria-label={t.common.removeAttachment} 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) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (onRemove) { if (onRemove) {

View File

@ -62,8 +62,8 @@ export const Suggestion = ({
<Button <Button
className={cn( className={cn(
"cursor-pointer rounded-full px-[20px] py-[15px] text-sm font-normal", "cursor-pointer rounded-full px-[20px] py-[15px] text-sm font-normal",
"border-none bg-ws-surface-subtle text-ws-text-muted", "border-none bg-ws-f9f8fa text-ws-667085",
"hover:bg-ws-surface-elevated hover:text-ws-base-1", "hover:bg-ws-fbfafc hover:text-ws-150033",
className, className,
)} )}
onClick={handleClick} onClick={handleClick}

View File

@ -16,7 +16,7 @@ function ScrollArea({
return ( return (
<ScrollAreaPrimitive.Root <ScrollAreaPrimitive.Root
data-slot="scroll-area" data-slot="scroll-area"
className={cn("relative overflow-hidden", className)} className={cn("relative", className)}
{...props} {...props}
> >
<ScrollAreaPrimitive.Viewport <ScrollAreaPrimitive.Viewport

View File

@ -309,7 +309,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main <main
data-slot="sidebar-inset" data-slot="sidebar-inset"
className={cn( 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", "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, className,
)} )}

View File

@ -430,7 +430,7 @@ export function ArtifactFileDetail({
type="single" type="single"
variant={null} variant={null}
size="default" size="default"
className="bg-ws-surface-base h-[28px]" className="h-[28px] bg-ws-ffffff"
value={viewMode} value={viewMode}
onValueChange={(value) => { onValueChange={(value) => {
if (value) { if (value) {
@ -721,7 +721,7 @@ export function ArtifactFileDetail({
</ArtifactHeader> </ArtifactHeader>
<ArtifactContent> <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 && {previewable &&
viewMode === "preview" && viewMode === "preview" &&
(language === "markdown" || language === "html") && ( (language === "markdown" || language === "html") && (
@ -734,7 +734,7 @@ export function ArtifactFileDetail({
/> />
)} )}
{isCodeFile && viewMode === "code" && ( {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 <CodeEditor
className="size-full resize-none rounded-none border-none py-[20px]" className="size-full resize-none rounded-none border-none py-[20px]"
value={displayContent ?? ""} value={displayContent ?? ""}
@ -917,7 +917,7 @@ export function ArtifactFilePreview({
if (language === "markdown") { if (language === "markdown") {
return ( return (
<div <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} style={{ "--zoom-scale": zoomScale } as CSSProperties}
> >
<Streamdown <Streamdown
@ -974,7 +974,7 @@ function PreviewIframe({
{...props} {...props}
/> />
{isLoading && ( {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" /> <LoaderIcon className="text-muted-foreground size-5 animate-spin" />
</div> </div>
)} )}
@ -1046,7 +1046,7 @@ function ArtifactPdfPreview({
const pageWrapper = document.createElement("div"); const pageWrapper = document.createElement("div");
pageWrapper.className = 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"); const canvas = document.createElement("canvas");
canvas.style.width = `${viewport.width}px`; canvas.style.width = `${viewport.width}px`;
@ -1089,13 +1089,8 @@ function ArtifactPdfPreview({
if (error) { if (error) {
return ( return (
<div <div className={cn("relative overflow-auto bg-ws-f9f8fa p-4", className)}>
className={cn( <div className="mx-auto grid max-w-xl gap-3 rounded-md border border-ws-e4e7ec bg-ws-ffffff p-5 text-center">
"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">
<p className="text-sm font-medium break-all">{fileName}</p> <p className="text-sm font-medium break-all">{fileName}</p>
<p className="text-muted-foreground text-sm">{error}</p> <p className="text-muted-foreground text-sm">{error}</p>
<a <a
@ -1112,20 +1107,15 @@ function ArtifactPdfPreview({
} }
return ( return (
<div <div className={cn("relative overflow-auto bg-ws-f9f8fa p-4", className)}>
className={cn( <div className="mb-3 text-center text-xs text-ws-667085">
"bg-ws-surface-subtle relative overflow-auto p-4",
className,
)}
>
<div className="text-ws-text-muted mb-3 text-center text-xs">
{pageCount > 0 {pageCount > 0
? t.artifactPreview.pageCountLabel(fileName, pageCount) ? t.artifactPreview.pageCountLabel(fileName, pageCount)
: fileName} : fileName}
</div> </div>
<div ref={containerRef} /> <div ref={containerRef} />
{isLoading && ( {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" /> <LoaderIcon className="text-muted-foreground size-5 animate-spin" />
</div> </div>
)} )}
@ -1323,12 +1313,7 @@ function ArtifactOfficePreview({
}, [canRenderPptx, t.artifactPreview.pptxDownloadHint]); }, [canRenderPptx, t.artifactPreview.pptxDownloadHint]);
return ( return (
<div <div className={cn("relative h-full overflow-hidden bg-ws-ffffff", className)}>
className={cn(
"bg-ws-surface-base relative h-full overflow-hidden",
className,
)}
>
{canRenderXlsx && sheetNames.length > 0 && ( {canRenderXlsx && sheetNames.length > 0 && (
<div className="border-border flex items-center gap-1 overflow-x-auto border-b p-2"> <div className="border-border flex items-center gap-1 overflow-x-auto border-b p-2">
{sheetNames.map((sheetName) => ( {sheetNames.map((sheetName) => (
@ -1338,7 +1323,7 @@ function ArtifactOfficePreview({
className={cn( className={cn(
"rounded px-4 py-3 text-xs whitespace-nowrap", "rounded px-4 py-3 text-xs whitespace-nowrap",
activeSheet === sheetName activeSheet === sheetName
? "bg-ws-accent-tint-soft text-foreground" ? "bg-ws-1500331a text-foreground"
: "text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:text-foreground",
)} )}
onClick={() => setActiveSheet(sheetName)} onClick={() => setActiveSheet(sheetName)}
@ -1372,7 +1357,7 @@ function ArtifactOfficePreview({
/> />
)} )}
{isLoading && ( {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" /> <LoaderIcon className="text-muted-foreground size-5 animate-spin" />
</div> </div>
)} )}
@ -1391,7 +1376,7 @@ function ArtifactPreviewFallback({
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
return ( 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-foreground mb-2 text-sm font-medium">{fileName}</p>
<p className="text-muted-foreground mb-3 text-xs">{message}</p> <p className="text-muted-foreground mb-3 text-xs">{message}</p>
<a <a
@ -1415,23 +1400,9 @@ function rewriteArtifactImagePaths(
return content; 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 toArtifactUrl = (rawPath: string) => {
const trimmedPath = rawPath.trim(); const normalizedPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
const normalizedPath = trimmedPath.startsWith("/") return resolveArtifactURL(normalizedPath, threadId);
? trimmedPath
: `/${trimmedPath}`;
return resolveArtifactURL(encodeVirtualPath(normalizedPath), threadId);
}; };
const toArtifactUrlFromRelative = (rawPath: string) => { const toArtifactUrlFromRelative = (rawPath: string) => {
const trimmed = rawPath.trim(); const trimmed = rawPath.trim();
@ -1445,17 +1416,17 @@ function rewriteArtifactImagePaths(
const absolutePath = new URL(trimmed, `file://${baseDir}`).pathname; const absolutePath = new URL(trimmed, `file://${baseDir}`).pathname;
if (!absolutePath.startsWith("/mnt/user-data/")) return null; if (!absolutePath.startsWith("/mnt/user-data/")) return null;
return resolveArtifactURL(encodeVirtualPath(absolutePath), threadId); return resolveArtifactURL(absolutePath, threadId);
}; };
const markdownRewritten = content.replace( const markdownRewritten = content.replace(
/!\[([^\]]*)\]\(\s*(\/?mnt\/user-data\/(?:outputs|uploads)\/[^)]+?)\s*\)/g, /!\[([^\]]*)\]\(\s*(\/?mnt\/user-data\/outputs\/[^)\s]+)\s*\)/g,
(_full, alt, rawPath) => { (_full, alt, rawPath) => {
return `![${alt}](${toArtifactUrl(rawPath)})`; return `![${alt}](${toArtifactUrl(rawPath)})`;
}, },
); );
const markdownRelativeRewritten = markdownRewritten.replace( const markdownRelativeRewritten = markdownRewritten.replace(
/!\[([^\]]*)\]\(\s*([^)]+?)\s*\)/g, /!\[([^\]]*)\]\(\s*([^) \t]+)\s*\)/g,
(_full, alt, rawPath) => { (_full, alt, rawPath) => {
const absoluteUrl = toArtifactUrlFromRelative(rawPath); const absoluteUrl = toArtifactUrlFromRelative(rawPath);
if (!absoluteUrl) { if (!absoluteUrl) {
@ -1466,7 +1437,7 @@ function rewriteArtifactImagePaths(
); );
const shorthandMarkdownRewritten = markdownRelativeRewritten.replace( 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) => { (_full, alt, rawPath) => {
return `![${String(alt).trim()}](${toArtifactUrl(rawPath)})`; return `![${String(alt).trim()}](${toArtifactUrl(rawPath)})`;
}, },
@ -1475,7 +1446,7 @@ function rewriteArtifactImagePaths(
return shorthandMarkdownRewritten.replace( return shorthandMarkdownRewritten.replace(
/(<img\b[^>]*\bsrc\s*=\s*)(["'])([^"']+)\2/gi, /(<img\b[^>]*\bsrc\s*=\s*)(["'])([^"']+)\2/gi,
(_full, prefix, quote, rawPath) => { (_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}`; return `${prefix}${quote}${toArtifactUrl(rawPath)}${quote}`;
} }
const absoluteUrl = toArtifactUrlFromRelative(rawPath); const absoluteUrl = toArtifactUrlFromRelative(rawPath);
@ -1588,34 +1559,34 @@ function buildArtifactViewerSrcDoc({
<meta name="viewport" content="width=device-width,initial-scale=1" /> <meta name="viewport" content="width=device-width,initial-scale=1" />
<style> <style>
:root { :root {
--ws-color-surface-app: rgb(248 249 251); --ws-color-f8f9fb: rgb(248 249 251);
--ws-color-surface-base: rgb(255 255 255); --ws-color-ffffff: rgb(255 255 255);
--ws-color-text-primary-strong: rgb(15 23 42); --ws-color-0f172a: rgb(15 23 42);
--ws-color-text-muted: rgb(102 112 133); --ws-color-667085: rgb(102 112 133);
--ws-color-line-default: rgb(228 231 236); --ws-color-e4e7ec: rgb(228 231 236);
--ws-color-surface-checker: rgb(244 244 245); --ws-color-f4f4f5: rgb(244 244 245);
--ws-color-black-solid: rgb(0 0 0); --ws-color-000000: rgb(0 0 0);
--ws-color-info-primary: rgb(37 99 235); --ws-color-2563eb: rgb(37 99 235);
--bg: var(--ws-color-surface-app); --bg: var(--ws-color-f8f9fb);
--panel: var(--ws-color-surface-base); --panel: var(--ws-color-ffffff);
--text: var(--ws-color-text-primary-strong); --text: var(--ws-color-0f172a);
--muted: var(--ws-color-text-muted); --muted: var(--ws-color-667085);
--line: var(--ws-color-line-default); --line: var(--ws-color-e4e7ec);
--checker: var(--ws-color-surface-checker); --checker: var(--ws-color-f4f4f5);
--media-bg: var(--ws-color-black-solid); --media-bg: var(--ws-color-000000);
--link: var(--ws-color-info-primary); --link: var(--ws-color-2563eb);
--radius: 12px; --radius: 12px;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--ws-color-surface-app: rgb(32 36 44); --ws-color-f8f9fb: rgb(32 36 44);
--ws-color-surface-base: rgb(42 39 49); --ws-color-ffffff: rgb(42 39 49);
--ws-color-text-primary-strong: rgb(230 234 242); --ws-color-0f172a: rgb(230 234 242);
--ws-color-text-muted: rgb(152 162 179); --ws-color-667085: rgb(152 162 179);
--ws-color-line-default: rgb(58 61 69); --ws-color-e4e7ec: rgb(58 61 69);
--ws-color-surface-checker: rgb(44 47 56); --ws-color-f4f4f5: rgb(44 47 56);
--ws-color-black-solid: rgb(0 0 0); --ws-color-000000: rgb(0 0 0);
--ws-color-info-primary: rgb(127 178 255); --ws-color-2563eb: rgb(127 178 255);
} }
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@ -1765,12 +1736,7 @@ export const ArtifactZoomSelector = ({
viewBox="0 0 16 16" viewBox="0 0 16 16"
fill="none" fill="none"
> >
<circle <circle cx="7.55558" cy="7.55534" r="6.16667" stroke="currentColor" />
cx="7.55558"
cy="7.55534"
r="6.16667"
stroke="currentColor"
/>
<path <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" 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" fill="currentColor"

View File

@ -104,7 +104,7 @@ export function ArtifactFileList({
<div className="absolute top-5 left-4"> <div className="absolute top-5 left-4">
{getFileIcon( {getFileIcon(
file, file,
"size-9 stroke-1 text-ws-fg-primary stroke-current", "size-9 stroke-1 text-ws-333333 stroke-current",
)} )}
</div> </div>
<CardDescription className="pl-10 text-xs"> <CardDescription className="pl-10 text-xs">
@ -137,7 +137,7 @@ export function ArtifactFileList({
> >
<Button <Button
variant="ghost" 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" /> <DownloadIcon className="size-4" />
{t.common.download} {t.common.download}

View File

@ -5,7 +5,6 @@ import type { GroupImperativeHandle } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation"; import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { sanitizeArtifactPaths } from "@/core/artifacts/utils";
import { import {
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
@ -44,10 +43,6 @@ const ChatBox: React.FC<{
deselect, deselect,
selectedArtifact, selectedArtifact,
} = useArtifacts(); } = useArtifacts();
const sanitizedArtifacts = useMemo(
() => sanitizeArtifactPaths(thread.values.artifacts),
[thread.values.artifacts],
);
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
useEffect(() => { useEffect(() => {
@ -57,7 +52,7 @@ const ChatBox: React.FC<{
} }
// Update artifacts from the current thread // 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. // DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
// if ( // if (
@ -71,19 +66,19 @@ const ChatBox: React.FC<{
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
autoSelectFirstArtifact autoSelectFirstArtifact
) { ) {
if (sanitizedArtifacts.length > 0) { if (thread?.values?.artifacts?.length > 0) {
setAutoSelectFirstArtifact(false); setAutoSelectFirstArtifact(false);
selectArtifact(sanitizedArtifacts[0]!); selectArtifact(thread.values.artifacts[0]!);
} }
} }
}, [ }, [
threadId, threadId,
autoSelectFirstArtifact, autoSelectFirstArtifact,
deselect, deselect,
sanitizedArtifacts,
selectArtifact, selectArtifact,
selectedArtifact, selectedArtifact,
setArtifacts, setArtifacts,
thread.values.artifacts,
]); ]);
const artifactPanelOpen = useMemo(() => { const artifactPanelOpen = useMemo(() => {
@ -156,7 +151,7 @@ const ChatBox: React.FC<{
<XIcon /> <XIcon />
</Button> </Button>
</div> </div>
{sanitizedArtifacts.length === 0 ? ( {thread.values.artifacts?.length === 0 ? (
<ConversationEmptyState <ConversationEmptyState
icon={<FilesIcon />} icon={<FilesIcon />}
title={t.chatPage.noArtifactSelectedTitle} title={t.chatPage.noArtifactSelectedTitle}
@ -172,7 +167,7 @@ const ChatBox: React.FC<{
<main className="min-h-0 grow"> <main className="min-h-0 grow">
<ArtifactFileList <ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12" className="max-w-(--container-width-sm) p-4 pt-12"
files={sanitizedArtifacts} files={thread.values.artifacts ?? []}
threadId={threadId ?? ""} threadId={threadId ?? ""}
/> />
</main> </main>

View File

@ -34,7 +34,7 @@ export function DevTodoList({
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className={cn( 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, className,
)} )}
align="start" align="start"

View File

@ -157,7 +157,7 @@ export function IframeTestPanel() {
<div <div
ref={panelRef} ref={panelRef}
className={cn( 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", position ? "top-0 left-0" : "bottom-24 left-3",
)} )}
style={position ? { left: position.x, top: position.y } : undefined} style={position ? { left: position.x, top: position.y } : undefined}

View File

@ -70,7 +70,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Tag } from "@/components/ui/tag"; import { Tag } from "@/components/ui/tag";
import { useReferenceFiles } from "@/core/artifacts/references";
import { urlOfArtifact } from "@/core/artifacts/utils"; import { urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import type { SelectedSkillPayloadItem } from "@/core/i18n/locales/types"; import type { SelectedSkillPayloadItem } from "@/core/i18n/locales/types";
@ -81,6 +80,7 @@ import {
MENTION_REFERENCE_EVENT, MENTION_REFERENCE_EVENT,
type MentionReferenceEventDetail, type MentionReferenceEventDetail,
} from "@/core/threads/reference-events"; } from "@/core/threads/reference-events";
import { useUploadedFiles } from "@/core/uploads/hooks";
import { useIframeSkill } from "@/hooks/use-iframe-skill"; import { useIframeSkill } from "@/hooks/use-iframe-skill";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -96,6 +96,7 @@ import {
import { Suggestion, Suggestions } from "../ai-elements/suggestion"; import { Suggestion, Suggestions } from "../ai-elements/suggestion";
import { ScrollArea } from "../ui/scroll-area"; import { ScrollArea } from "../ui/scroll-area";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide"; import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip"; import { Tooltip } from "./tooltip";
@ -148,7 +149,7 @@ function WorkspaceToolButton({
return ( return (
<PromptInputButton <PromptInputButton
className={cn( 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, className,
)} )}
{...props} {...props}
@ -259,6 +260,7 @@ export function InputBox({
}), }),
[t], [t],
); );
const { thread } = useThread();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const iframeSkill = useIframeSkill({ threadId: threadIdFromProps }); const iframeSkill = useIframeSkill({ threadId: threadIdFromProps });
const isInputDisabled = (disabled ?? false) || iframeSkill.isBootstrapping; const isInputDisabled = (disabled ?? false) || iframeSkill.isBootstrapping;
@ -292,7 +294,7 @@ export function InputBox({
} | null>(null); } | null>(null);
const [isInputToolsTourOpen, setIsInputToolsTourOpen] = useState(false); const [isInputToolsTourOpen, setIsInputToolsTourOpen] = useState(false);
const [isInputToolsTourReady, setIsInputToolsTourReady] = useState(false); const [isInputToolsTourReady, setIsInputToolsTourReady] = useState(false);
const { data: referenceFilesData } = useReferenceFiles(threadIdFromProps); const { data: uploadedFilesData } = useUploadedFiles(threadIdFromProps);
// isNewThread 时禁用收缩,始终保持展开(除非已提交消息) // isNewThread 时禁用收缩,始终保持展开(除非已提交消息)
const effectiveIsFocused = const effectiveIsFocused =
@ -437,41 +439,49 @@ export function InputBox({
); );
const mentionCandidates = useMemo<MentionCandidate[]>(() => { const mentionCandidates = useMemo<MentionCandidate[]>(() => {
const deduped = new Map<string, MentionCandidate>(); const artifactCandidates = (thread.values.artifacts ?? []).map((path) => {
(referenceFilesData?.files ?? []).forEach((file) => { const filename = path.split("/").pop() ?? path;
const path = file.virtual_path || ""; return {
const filename = file.filename ?? path.split("/").pop() ?? path; key: `artifact:${path}`,
const refSource = file.source === "upload" ? "upload" : "artifact"; filename,
const typeLabel = path,
refSource === "upload" pathTail: getPathTail(path),
? referenceSourceLabels.upload ref_source: "artifact" as const,
: referenceSourceLabels.artifact; ref_kind: "mention" as const,
const previewUrl = typeLabel: referenceSourceLabels.artifact,
file.artifact_url || isImage: isImageFilename(filename),
(threadId previewUrl: threadId
? urlOfArtifact({ ? urlOfArtifact({
filepath: path, filepath: path,
threadId, 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()]; return [...deduped.values()];
}, [ }, [
referenceFilesData?.files,
referenceSourceLabels.artifact, referenceSourceLabels.artifact,
referenceSourceLabels.upload, referenceSourceLabels.upload,
thread.values.artifacts,
uploadedFilesData?.files,
threadId, threadId,
]); ]);
@ -879,12 +889,12 @@ export function InputBox({
textareaRef.current?.focus(); 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} {t.inputBox.addReference}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator className="mx-0 mt-[20px] mb-0" /> <DropdownMenuSeparator className="mx-0 mt-[20px] mb-0" />
<DropdownMenuGroup className="flex min-h-0 flex-col gap-[10px] px-0"> <DropdownMenuGroup className="flex max-h-[480px] flex-col gap-[10px] px-0 pt-[20px]">
<ScrollArea className="h-[320px] pt-[20px]" hideScrollbar={false}> <ScrollArea className="h-[480px]" data-state="hidden">
{filteredMentionCandidates.map((candidate, index) => { {filteredMentionCandidates.map((candidate, index) => {
const detail = [candidate.typeLabel, candidate.pathTail] const detail = [candidate.typeLabel, candidate.pathTail]
.filter(Boolean) .filter(Boolean)
@ -970,14 +980,6 @@ export function InputBox({
/> />
</div> </div>
)} )}
{!showWelcomeStyle && (
<div className="shrink-0 h-full">
<ExitChattingButton
router={router}
threadId={threadIdFromProps}
/>
</div>
)}
<div ref={attachmentsButtonTourRef} className="shrink-0 h-full"> <div ref={attachmentsButtonTourRef} className="shrink-0 h-full">
<AddAttachmentsButton /> <AddAttachmentsButton />
</div> </div>
@ -1232,7 +1234,7 @@ function AddAttachmentsButton({ className }: { className?: string }) {
return ( return (
<Tooltip content={t.inputBox.addAttachments}> <Tooltip content={t.inputBox.addAttachments}>
<WorkspaceToolButton <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()} onClick={() => attachments.openFileDialog()}
> >
<svg <svg
@ -1270,7 +1272,7 @@ function HistoryButton({
return ( return (
<Tooltip content={t.inputBox.history}> <Tooltip content={t.inputBox.history}>
<WorkspaceToolButton <WorkspaceToolButton
className={cn("text-ws-base-1 hover:text-ws-interactive-primary", className)} className={cn("text-ws-150033 hover:text-ws-8e47f0", className)}
onClick={() => onClick={() =>
router.replace(`/workspace/chats/${threadId}?is_chatting=true`) router.replace(`/workspace/chats/${threadId}?is_chatting=true`)
} }
@ -1300,53 +1302,6 @@ function HistoryButton({
</Tooltip> </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 // 启动iframeSkillDialog
function IframeSkillDialogButton({ function IframeSkillDialogButton({
className, className,
@ -1375,7 +1330,7 @@ function IframeSkillDialogButton({
> >
<svg <svg
xmlns="http://www.w3.org/2000/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" viewBox="0 0 12 16"
fill="none" fill="none"
> >

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { CheckIcon, CopyIcon, DownloadIcon } from "lucide-react"; import { CheckIcon, CopyIcon } from "lucide-react";
import { useCallback, useMemo, useState, type MouseEvent } from "react"; import { useCallback, useMemo, useState, type MouseEvent } from "react";
import type { import type {
AnchorHTMLAttributes, AnchorHTMLAttributes,
@ -56,57 +56,27 @@ function toMarkdownTable(data: TableData): string {
return [headerLine, dividerLine, ...rowLines].join("\n"); 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({ function MarkdownTable({
className, className,
children, children,
isLoading,
copyLabel, copyLabel,
downloadLabel,
...props ...props
}: ComponentPropsWithoutRef<"table"> & { }: ComponentPropsWithoutRef<"table"> & {
isLoading: boolean;
copyLabel: string; copyLabel: string;
downloadLabel: string;
}) { }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const getTableData = useCallback((event: MouseEvent<HTMLButtonElement>) => { const handleCopy = useCallback(
async (event: MouseEvent<HTMLButtonElement>) => {
const wrapper = event.currentTarget.closest( const wrapper = event.currentTarget.closest(
'[data-streamdown="table-wrapper"]', '[data-streamdown="table-wrapper"]',
); );
const table = wrapper?.querySelector("table"); const table = wrapper?.querySelector("table");
if (!(table instanceof HTMLTableElement)) return null; if (!(table instanceof HTMLTableElement)) return;
return parseTableData(table);
}, []);
const handleCopy = useCallback( const markdown = toMarkdownTable(parseTableData(table));
async (event: MouseEvent<HTMLButtonElement>) => {
const data = getTableData(event);
if (!data) return;
const markdown = toMarkdownTable(data);
if (!markdown) return; if (!markdown) return;
try { try {
@ -117,20 +87,7 @@ function MarkdownTable({
// no-op // 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 ( return (
@ -140,21 +97,14 @@ function MarkdownTable({
> >
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<button <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} onClick={handleCopy}
title={copyLabel} title={copyLabel}
type="button" type="button"
> >
{copied ? <CheckIcon size={14} /> : <CopyIcon size={14} />} {copied ? <CheckIcon size={14} /> : <CopyIcon size={14} />}
</button> </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>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table <table
@ -215,7 +165,7 @@ export function MarkdownContent({
<MarkdownTable <MarkdownTable
className={className} className={className}
copyLabel={t.clipboard.copyToClipboard} copyLabel={t.clipboard.copyToClipboard}
downloadLabel={t.common.download} isLoading={isLoading}
{...props} {...props}
> >
{children} {children}
@ -223,12 +173,7 @@ export function MarkdownContent({
), ),
...componentsFromProps, ...componentsFromProps,
}; };
}, [ }, [componentsFromProps, isLoading, t.clipboard.copyToClipboard]);
componentsFromProps,
isLoading,
t.clipboard.copyToClipboard,
t.common.download,
]);
if (!content) return null; if (!content) return null;

View File

@ -114,7 +114,7 @@ export function MessageGroup({
); );
return ( return (
<ChainOfThought <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} open={true}
> >
{aboveLastToolCallSteps.length > 0 && ( {aboveLastToolCallSteps.length > 0 && (

View File

@ -27,11 +27,8 @@ import {
import { resolveArtifactURL } from "@/core/artifacts/utils"; import { resolveArtifactURL } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
extractSummaryTemplateBody,
extractContentFromMessage, extractContentFromMessage,
normalizeHumanMessageDisplayText,
extractReasoningContentFromMessage, extractReasoningContentFromMessage,
isSummaryTemplateMessage,
parseUploadedFiles, parseUploadedFiles,
stripPriorityHintSuffix, stripPriorityHintSuffix,
stripUploadedFilesTag, stripUploadedFilesTag,
@ -39,6 +36,7 @@ import {
} from "@/core/messages/utils"; } from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { materializeSkillYaml } from "@/core/skills"; import { materializeSkillYaml } from "@/core/skills";
import { humanMessagePlugins } from "@/core/streamdown";
import { dispatchMentionReference } from "@/core/threads/reference-events"; import { dispatchMentionReference } from "@/core/threads/reference-events";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -141,7 +139,6 @@ function MessageContent_({
isLoading?: boolean; isLoading?: boolean;
threadId: string; threadId: string;
}) { }) {
const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const isHuman = message.type === "human"; const isHuman = message.type === "human";
const components = useMemo( const components = useMemo(
@ -170,23 +167,12 @@ function MessageContent_({
const contentToDisplay = useMemo(() => { const contentToDisplay = useMemo(() => {
if (isHuman) { if (isHuman) {
if (!rawContent) { return rawContent
return ""; ? stripPriorityHintSuffix(stripUploadedFilesTag(rawContent))
} : "";
const cleaned = stripPriorityHintSuffix(stripUploadedFilesTag(rawContent));
return normalizeHumanMessageDisplayText(cleaned);
} }
return rawContent ?? ""; return rawContent ?? "";
}, [rawContent, isHuman]); }, [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 = const filesList =
files && files.length > 0 && threadId ? ( files && files.length > 0 && threadId ? (
@ -222,42 +208,20 @@ function MessageContent_({
} }
if (isHuman) { if (isHuman) {
const shouldRenderSummaryCollapse = isSummaryMessage && summaryBody;
const messageResponse = contentToDisplay ? ( const messageResponse = contentToDisplay ? (
<div className="whitespace-break-spaces break-words"> <AIElementMessageResponse
remarkPlugins={humanMessagePlugins.remarkPlugins}
rehypePlugins={humanMessagePlugins.rehypePlugins}
components={components}
>
{contentToDisplay} {contentToDisplay}
</div> </AIElementMessageResponse>
) : null; ) : null;
return ( return (
<div className={cn("ml-auto flex flex-col gap-2", className)}> <div className={cn("ml-auto flex flex-col gap-2", className)}>
{filesList} {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 && ( {messageResponse && (
<AIElementMessageContent <AIElementMessageContent className="w-fit">
className={cn(
"w-fit",
shouldRenderSummaryCollapse ? "hidden" : undefined,
)}
>
{messageResponse} {messageResponse}
</AIElementMessageContent> </AIElementMessageContent>
)} )}

View File

@ -225,7 +225,7 @@ export function MessageList({
{showScrollToBottomButton && ( {showScrollToBottomButton && (
<ConversationScrollButton <ConversationScrollButton
className={cn( 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, scrollButtonClassName,
)} )}
title={t.chats.scrollToBottom} title={t.chats.scrollToBottom}

View File

@ -157,7 +157,7 @@ function ThemePreviewCard({
"relative overflow-hidden rounded-md border text-xs transition-colors", "relative overflow-hidden rounded-md border text-xs transition-colors",
previewMode === "dark" previewMode === "dark"
? "border-neutral-800 bg-neutral-900 text-neutral-200" ? "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"> <div className="border-border/50 flex items-center gap-2 border-b px-3 py-2">

View File

@ -14,19 +14,19 @@ export function StreamingIndicator({
<div <div
className={cn( className={cn(
dotSize, dotSize,
"animate-bouncing rounded-full bg-ws-icon-muted opacity-100", "animate-bouncing rounded-full bg-ws-a3a1a1 opacity-100",
)} )}
/> />
<div <div
className={cn( className={cn(
dotSize, 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 <div
className={cn( className={cn(
dotSize, 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> </div>

View File

@ -39,7 +39,7 @@ export function TodoList({
return ( return (
<div <div
className={cn( 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" : "", hidden ? "pointer-events-none translate-y-8 opacity-0" : "",
className, className,
)} )}

View File

@ -43,7 +43,7 @@ export function WorkspaceHeader({ className }: { className?: string }) {
) : ( ) : (
<div className="text-primary ml-2 cursor-default font-serif"> <div className="text-primary ml-2 cursor-default font-serif">
{/* TODO: 测试标识 */} {/* 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> </div>
)} )}
<SidebarTrigger /> <SidebarTrigger />

View File

@ -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,
});
}

View File

@ -1,8 +1,6 @@
import { getBackendBaseURL } from "../config"; import { getBackendBaseURL } from "../config";
import type { AgentThread } from "../threads"; import type { AgentThread } from "../threads";
const ARTIFACTS_REPLACE_SENTINEL = "__deerflow_replace_artifacts__";
export function urlOfArtifact({ export function urlOfArtifact({
filepath, filepath,
threadId, threadId,
@ -21,13 +19,9 @@ export function urlOfArtifact({
} }
export function extractArtifactsFromThread(thread: AgentThread) { export function extractArtifactsFromThread(thread: AgentThread) {
return sanitizeArtifactPaths(thread.values.artifacts); return thread.values.artifacts ?? [];
} }
export function resolveArtifactURL(absolutePath: string, threadId: string) { export function resolveArtifactURL(absolutePath: string, threadId: string) {
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${absolutePath}`; return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${absolutePath}`;
} }
export function sanitizeArtifactPaths(paths: string[] | undefined | null) {
return (paths ?? []).filter((path) => path !== ARTIFACTS_REPLACE_SENTINEL);
}

View File

@ -86,7 +86,6 @@ export const enUS: Translations = {
"Please note, this feature will consume tokens. Ensure your account balance is greater than 200 credits.", "Please note, this feature will consume tokens. Ensure your account balance is greater than 200 credits.",
addAttachments: "Add attachments", addAttachments: "Add attachments",
history: "History", history: "History",
welcome:"Welcome",
selectSkill: "Select Skill", selectSkill: "Select Skill",
mode: "Mode", mode: "Mode",
flashMode: "Flash", flashMode: "Flash",

View File

@ -75,7 +75,6 @@ export interface Translations {
createSkillPrompt: string; createSkillPrompt: string;
addAttachments: string; addAttachments: string;
history: string; history: string;
welcome:string;
selectSkill: string; selectSkill: string;
mode: string; mode: string;
flashMode: string; flashMode: string;

View File

@ -87,7 +87,6 @@ export const zhCN: Translations = {
"请注意此功能将消耗token请保证账户余额大于200可学豆。", "请注意此功能将消耗token请保证账户余额大于200可学豆。",
addAttachments: "添加附件", addAttachments: "添加附件",
history: "历史记录", history: "历史记录",
welcome:"欢迎页",
selectSkill: "选择Skill", selectSkill: "选择Skill",
mode: "模式", mode: "模式",
flashMode: "闪速", flashMode: "闪速",
@ -135,13 +134,13 @@ export const zhCN: Translations = {
suggestion: "GPT-Image-2", suggestion: "GPT-Image-2",
prompt: "编写[项目/功能]的需求文档,包含功能描述、用户故事和验收标准。", prompt: "编写[项目/功能]的需求文档,包含功能描述、用户故事和验收标准。",
icon: CompassIcon, icon: CompassIcon,
children: [{ id: "6130", name: "GPT-Image-2" }], children: [{ id: "6107", name: "GPT-Image-2" }],
}, },
{ {
suggestion: "音乐生成", suggestion: "音乐生成",
prompt: "编写[产品/功能]的使用指南,包含操作步骤、注意事项和常见问题。", prompt: "编写[产品/功能]的使用指南,包含操作步骤、注意事项和常见问题。",
icon: GraduationCapIcon, icon: GraduationCapIcon,
children: [{ id: "6133", name: "音乐生成器" }], children: [{ id: "6126", name: "旋律制造机" }],
}, },
{ {
suggestion: "excel数据处理", suggestion: "excel数据处理",
@ -263,7 +262,7 @@ export const zhCN: Translations = {
noArtifactSelectedDescription: "请选择一个生成文件以查看详情", noArtifactSelectedDescription: "请选择一个生成文件以查看详情",
exitDialogTitle: "提示", exitDialogTitle: "提示",
exitDialogDescription: exitDialogDescription:
"每七天自动删除。现在将返回欢迎页且清空聊天消息,是否继续?", "历史记录每七天自动删除,现在将返回欢迎页,是否继续?",
exitDialogConfirm: "确定", exitDialogConfirm: "确定",
selectedSkillLoadFailed: "技能加载失败", selectedSkillLoadFailed: "技能加载失败",
unknownErrorRetry: "发生了未知错误,请稍后重试。", unknownErrorRetry: "发生了未知错误,请稍后重试。",

View File

@ -26,47 +26,6 @@ type MessageGroup =
| AssistantClarificationGroup | AssistantClarificationGroup
| AssistantSubagentGroup; | 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>( export function groupMessages<T>(
messages: Message[], messages: Message[],
mapper: (group: MessageGroup) => T, mapper: (group: MessageGroup) => T,
@ -98,9 +57,6 @@ export function groupMessages<T>(
} }
if (message.type === "human") { if (message.type === "human") {
// if (isSummaryTemplateMessage(message)) {
// continue;
// }
groups.push({ id: message.id, type: "human", messages: [message] }); groups.push({ id: message.id, type: "human", messages: [message] });
continue; continue;
} }
@ -408,17 +364,6 @@ export function stripPriorityHintSuffix(content: string): string {
.trim(); .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[] { export function parseUploadedFiles(content: string): FileInMessage[] {
// Match <uploaded_files>...</uploaded_files> tag // Match <uploaded_files>...</uploaded_files> tag
const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/; const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/;

View File

@ -201,24 +201,24 @@
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-tooltip-background: var(--tooltip-background); --color-tooltip-background: var(--tooltip-background);
--color-ws-base-1: var(--ws-color-base-1); --color-ws-150033: var(--ws-color-150033);
--color-ws-fg-primary: var(--ws-color-fg-primary); --color-ws-333333: var(--ws-color-333333);
--color-ws-surface-subtle: var(--ws-color-surface-subtle); --color-ws-f9f8fa: var(--ws-color-f9f8fa);
--color-ws-surface-elevated: var(--ws-color-surface-elevated); --color-ws-fbfafc: var(--ws-color-fbfafc);
--color-ws-interactive-primary: var(--ws-color-interactive-primary); --color-ws-8e47f0: var(--ws-color-8e47f0);
--color-ws-line-default: var(--ws-color-line-default); --color-ws-e4e7ec: var(--ws-color-e4e7ec);
--color-ws-text-muted: var(--ws-color-text-muted); --color-ws-667085: var(--ws-color-667085);
--color-ws-icon-muted: var(--ws-color-icon-muted); --color-ws-a3a1a1: var(--ws-color-a3a1a1);
--color-ws-overlay-neutral: var(--ws-color-overlay-neutral); --color-ws-999999: var(--ws-color-999999);
--color-ws-text-subtle-strong: var(--ws-color-text-subtle-strong); --color-ws-000000c5: var(--ws-color-000000c5);
--color-ws-border-hairline: var(--ws-color-border-hairline); --color-ws-00000015: var(--ws-color-00000015);
--color-ws-accent-tint-soft: var(--ws-color-accent-tint-soft); --color-ws-1500331a: var(--ws-color-1500331a);
--color-ws-surface-app: var(--ws-color-surface-app); --color-ws-f8f9fb: var(--ws-color-f8f9fb);
--color-ws-surface-base: var(--ws-color-surface-base); --color-ws-ffffff: var(--ws-color-ffffff);
--color-ws-text-primary-strong: var(--ws-color-text-primary-strong); --color-ws-0f172a: var(--ws-color-0f172a);
--color-ws-surface-checker: var(--ws-color-surface-checker); --color-ws-f4f4f5: var(--ws-color-f4f4f5);
--color-ws-black-solid: var(--ws-color-black-solid); --color-ws-000000: var(--ws-color-000000);
--color-ws-info-primary: var(--ws-color-info-primary); --color-ws-2563eb: var(--ws-color-2563eb);
--animate-aurora: aurora 8s ease-in-out infinite alternate; --animate-aurora: aurora 8s ease-in-out infinite alternate;
@keyframes aurora { @keyframes aurora {
@ -307,24 +307,24 @@
--sidebar-border: oklch(0.922 0.0098 87.47); --sidebar-border: oklch(0.922 0.0098 87.47);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
--tooltip-background: #00000066; --tooltip-background: #00000066;
--ws-color-base-1: #150033; --ws-color-150033: #150033;
--ws-color-fg-primary: #333333; --ws-color-333333: #333333;
--ws-color-surface-subtle: #f9f8fa; --ws-color-f9f8fa: #f9f8fa;
--ws-color-surface-elevated: #fbfafc; --ws-color-fbfafc: #fbfafc;
--ws-color-interactive-primary: #8e47f0; --ws-color-8e47f0: #8e47f0;
--ws-color-line-default: #e4e7ec; --ws-color-e4e7ec: #e4e7ec;
--ws-color-text-muted: #667085; --ws-color-667085: #667085;
--ws-color-icon-muted: #a3a1a1; --ws-color-a3a1a1: #a3a1a1;
--ws-color-overlay-neutral: #999999; --ws-color-999999: #999999;
--ws-color-text-subtle-strong: #000000c5; --ws-color-000000c5: #000000c5;
--ws-color-border-hairline: #00000015; --ws-color-00000015: #00000015;
--ws-color-accent-tint-soft: #1500331a; --ws-color-1500331a: #1500331a;
--ws-color-surface-app: #f8f9fb; --ws-color-f8f9fb: #f8f9fb;
--ws-color-surface-base: #ffffff; --ws-color-ffffff: #ffffff;
--ws-color-text-primary-strong: #0f172a; --ws-color-0f172a: #0f172a;
--ws-color-surface-checker: #f4f4f5; --ws-color-f4f4f5: #f4f4f5;
--ws-color-black-solid: #000000; --ws-color-000000: #000000;
--ws-color-info-primary: #2563eb; --ws-color-2563eb: #2563eb;
} }
.dark { .dark {
@ -360,24 +360,24 @@
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.556 0 0);
--tooltip-background: oklch(0.85 0 0); --tooltip-background: oklch(0.85 0 0);
--ws-color-base-1: #f4ebff; --ws-color-150033: #f4ebff;
--ws-color-fg-primary: #f5f5f5; --ws-color-333333: #f5f5f5;
--ws-color-surface-subtle: #1f1f1f; --ws-color-f9f8fa: #1f1f1f;
--ws-color-surface-elevated: #24222a; --ws-color-fbfafc: #24222a;
--ws-color-interactive-primary: #b987ff; --ws-color-8e47f0: #b987ff;
--ws-color-line-default: #3b3f48; --ws-color-e4e7ec: #3b3f48;
--ws-color-text-muted: #98a2b3; --ws-color-667085: #98a2b3;
--ws-color-icon-muted: #d0d0d0; --ws-color-a3a1a1: #d0d0d0;
--ws-color-overlay-neutral: #c2c2c2; --ws-color-999999: #c2c2c2;
--ws-color-text-subtle-strong: #ffffffcc; --ws-color-000000c5: #ffffffcc;
--ws-color-border-hairline: #ffffff1f; --ws-color-00000015: #ffffff1f;
--ws-color-accent-tint-soft: #f4ebff24; --ws-color-1500331a: #f4ebff24;
--ws-color-surface-app: #20242c; --ws-color-f8f9fb: #20242c;
--ws-color-surface-base: #2a2731; --ws-color-ffffff: #2a2731;
--ws-color-text-primary-strong: #e6eaf2; --ws-color-0f172a: #e6eaf2;
--ws-color-surface-checker: #2c2f38; --ws-color-f4f4f5: #2c2f38;
--ws-color-black-solid: #000000; --ws-color-000000: #000000;
--ws-color-info-primary: #7fb2ff; --ws-color-2563eb: #7fb2ff;
font-weight: 300; font-weight: 300;
} }

View File

@ -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 = { export type WorkspaceColorToken = {
light: `#${string}`; light: `#${string}`;
dark: `#${string}`; dark: `#${string}`;
}; };
// Token 键保持语义化且稳定:`ws-<role>-<level>`(不要再使用原始 hex 命名)。
export const WORKSPACE_COLOR_TOKENS = { export const WORKSPACE_COLOR_TOKENS = {
"ws-base-1": { light: "#150033", dark: "#f4ebff" }, "ws-150033": { light: "#150033", dark: "#f4ebff" },
"ws-fg-primary": { light: "#333333", dark: "#f5f5f5" }, "ws-333333": { light: "#333333", dark: "#f5f5f5" },
"ws-surface-subtle": { light: "#f9f8fa", dark: "#1f1f1f" }, "ws-f9f8fa": { light: "#f9f8fa", dark: "#1f1f1f" },
"ws-surface-elevated": { light: "#fbfafc", dark: "#24222a" }, "ws-fbfafc": { light: "#fbfafc", dark: "#24222a" },
"ws-interactive-primary": { light: "#8e47f0", dark: "#b987ff" }, "ws-8e47f0": { light: "#8e47f0", dark: "#b987ff" },
"ws-line-default": { light: "#e4e7ec", dark: "#3b3f48" }, "ws-e4e7ec": { light: "#e4e7ec", dark: "#3b3f48" },
"ws-text-muted": { light: "#667085", dark: "#98a2b3" }, "ws-667085": { light: "#667085", dark: "#98a2b3" },
"ws-icon-muted": { light: "#a3a1a1", dark: "#d0d0d0" }, "ws-a3a1a1": { light: "#a3a1a1", dark: "#d0d0d0" },
"ws-overlay-neutral": { light: "#999999", dark: "#c2c2c2" }, "ws-999999": { light: "#999999", dark: "#c2c2c2" },
"ws-text-subtle-strong": { light: "#000000c5", dark: "#ffffffcc" }, "ws-000000c5": { light: "#000000c5", dark: "#ffffffcc" },
"ws-border-hairline": { light: "#00000015", dark: "#ffffff1f" }, "ws-00000015": { light: "#00000015", dark: "#ffffff1f" },
"ws-accent-tint-soft": { light: "#1500331a", dark: "#f4ebff24" }, "ws-1500331a": { light: "#1500331a", dark: "#f4ebff24" },
"ws-surface-app": { light: "#f8f9fb", dark: "#20242c" }, "ws-f8f9fb": { light: "#f8f9fb", dark: "#20242c" },
"ws-surface-base": { light: "#ffffff", dark: "#2a2731" }, "ws-ffffff": { light: "#ffffff", dark: "#2a2731" },
"ws-text-primary-strong": { light: "#0f172a", dark: "#e6eaf2" }, "ws-0f172a": { light: "#0f172a", dark: "#e6eaf2" },
"ws-surface-checker": { light: "#f4f4f5", dark: "#2c2f38" }, "ws-f4f4f5": { light: "#f4f4f5", dark: "#2c2f38" },
"ws-black-solid": { light: "#000000", dark: "#000000" }, "ws-000000": { light: "#000000", dark: "#000000" },
"ws-info-primary": { light: "#2563eb", dark: "#7fb2ff" }, "ws-2563eb": { light: "#2563eb", dark: "#7fb2ff" },
} as const satisfies Record<string, WorkspaceColorToken>; } as const satisfies Record<string, WorkspaceColorToken>;