Compare commits

...

9 Commits

38 changed files with 927 additions and 244 deletions

View File

@ -10,6 +10,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, PlainTextResponse, Response
from app.gateway.path_utils import resolve_thread_virtual_path
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths
logger = logging.getLogger(__name__)
@ -62,6 +63,38 @@ def _find_compat_filename_match(missing_path: Path) -> Path | None:
return matches[0] if len(matches) == 1 else None
def _list_reference_files_in_dir(
thread_id: str,
root_dir: Path,
virtual_prefix: str,
source: str,
) -> list[dict[str, str]]:
if not root_dir.is_dir():
return []
files: list[dict[str, str]] = []
for file_path in sorted(root_dir.rglob("*")):
if not file_path.is_file():
continue
relative_path = file_path.relative_to(root_dir).as_posix()
# Internal uploaded skills are bootstrap assets, not user-facing references.
if source == "upload" and relative_path.startswith("skill/"):
continue
virtual_path = f"{virtual_prefix}/{relative_path}"
encoded_virtual_path = quote(virtual_path, safe="/")
files.append(
{
"filename": file_path.name,
"size": str(file_path.stat().st_size),
"virtual_path": virtual_path,
"artifact_url": f"/api/threads/{thread_id}/artifacts{encoded_virtual_path}",
"source": source,
}
)
return files
def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool:
"""Check if file is text by examining content for null bytes."""
try:
@ -106,6 +139,38 @@ def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> byte
return None
@router.get(
"/threads/{thread_id}/artifacts/list",
summary="List Reference Files",
description="List current files under outputs and uploads for @ references.",
)
async def list_reference_files(thread_id: str) -> dict:
"""List real files from outputs/uploads so mention candidates stay fresh."""
paths = get_paths()
outputs_dir = paths.sandbox_outputs_dir(thread_id)
uploads_dir = paths.sandbox_uploads_dir(thread_id)
outputs_virtual_prefix = f"{VIRTUAL_PATH_PREFIX}/outputs"
uploads_virtual_prefix = f"{VIRTUAL_PATH_PREFIX}/uploads"
output_files = _list_reference_files_in_dir(
thread_id,
outputs_dir,
outputs_virtual_prefix,
"artifact",
)
upload_files = _list_reference_files_in_dir(
thread_id,
uploads_dir,
uploads_virtual_prefix,
"upload",
)
files = [*output_files, *upload_files]
return {
"files": files,
"count": len(files),
}
@router.get(
"/threads/{thread_id}/artifacts/{path:path}",
summary="Get Artifact File",

View File

@ -2,10 +2,12 @@ import logging
from langchain.agents import create_agent
from langchain.agents.middleware import AgentMiddleware, SummarizationMiddleware
from langchain_core.messages.human import HumanMessage
from langchain_core.runnables import RunnableConfig
from deerflow.agents.lead_agent.prompt import apply_prompt_template
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
from deerflow.agents.middlewares.artifact_reconcile_middleware import ArtifactReconcileMiddleware
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
from deerflow.agents.middlewares.message_timestamp_middleware import MessageTimestampMiddleware
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
@ -23,6 +25,15 @@ from deerflow.models import create_chat_model
logger = logging.getLogger(__name__)
SUMMARY_MESSAGE_TITLE = "以下是目前对话的摘要:"
class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
"""Summarization middleware with DeerFlow's user-facing summary heading."""
def _build_new_messages(self, summary: str) -> list[HumanMessage]:
return [HumanMessage(content=f"{SUMMARY_MESSAGE_TITLE}\n\n{summary}")]
def _resolve_model_name(requested_model_name: str | None = None) -> str:
"""Resolve a runtime model name safely, falling back to default if invalid. Returns None if no models are configured."""
@ -78,7 +89,7 @@ def _create_summarization_middleware() -> SummarizationMiddleware | None:
if config.summary_prompt is not None:
kwargs["summary_prompt"] = config.summary_prompt
return SummarizationMiddleware(**kwargs)
return DeerFlowSummarizationMiddleware(**kwargs)
def _create_todo_list_middleware(is_plan_mode: bool) -> TodoMiddleware | None:
@ -234,6 +245,9 @@ def _build_middlewares(config: RunnableConfig, model_name: str | None, agent_nam
if get_app_config().token_usage.enabled:
middlewares.append(TokenUsageMiddleware())
# Reconcile stale artifact entries against real outputs files.
middlewares.append(ArtifactReconcileMiddleware())
# Stamp every conversation message with backend timestamp metadata.
middlewares.append(MessageTimestampMiddleware())

View File

@ -0,0 +1,117 @@
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

@ -2,6 +2,8 @@ from typing import Annotated, NotRequired, TypedDict
from langchain.agents import AgentState
ARTIFACTS_REPLACE_SENTINEL = "__deerflow_replace_artifacts__"
class SandboxState(TypedDict):
sandbox_id: NotRequired[str | None]
@ -20,12 +22,22 @@ class ViewedImageData(TypedDict):
def merge_artifacts(existing: list[str] | None, new: list[str] | None) -> list[str]:
"""Reducer for artifacts list - merges and deduplicates artifacts."""
def _clean(values: list[str] | None) -> list[str]:
if not values:
return []
return [v for v in values if isinstance(v, str) and v != ARTIFACTS_REPLACE_SENTINEL]
cleaned_existing = _clean(existing)
cleaned_new = _clean(new)
if new and new[0] == ARTIFACTS_REPLACE_SENTINEL:
return list(dict.fromkeys(cleaned_new))
if existing is None:
return new or []
return cleaned_new
if new is None:
return existing
return cleaned_existing
# Use dict.fromkeys to deduplicate while preserving order
return list(dict.fromkeys(existing + new))
return list(dict.fromkeys(cleaned_existing + cleaned_new))
def merge_viewed_images(existing: dict[str, ViewedImageData] | None, new: dict[str, ViewedImageData] | None) -> dict[str, ViewedImageData]:

View File

@ -0,0 +1,111 @@
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,3 +130,43 @@ def test_get_artifact_compat_fallback_for_dash_spacing(tmp_path, monkeypatch) ->
assert bytes(response.body).decode("utf-8") == "ok"
assert response.media_type == "text/markdown"
def test_list_reference_files_returns_outputs_and_uploads(tmp_path, monkeypatch) -> None:
outputs_dir = tmp_path / "outputs"
uploads_dir = tmp_path / "uploads"
outputs_dir.mkdir()
uploads_dir.mkdir()
(outputs_dir / "notes.md").write_text("hello", encoding="utf-8")
(outputs_dir / "figures").mkdir()
(outputs_dir / "figures" / "plot.png").write_bytes(b"png")
(uploads_dir / "dataset.csv").write_text("a,b\n1,2\n", encoding="utf-8")
(uploads_dir / "skill").mkdir()
(uploads_dir / "skill" / "internal.txt").write_text("hidden", encoding="utf-8")
class _FakePaths:
def sandbox_outputs_dir(self, _thread_id: str) -> Path:
return outputs_dir
def sandbox_uploads_dir(self, _thread_id: str) -> Path:
return uploads_dir
monkeypatch.setattr(artifacts_router, "get_paths", lambda: _FakePaths())
app = FastAPI()
app.include_router(artifacts_router.router)
with TestClient(app) as client:
response = client.get("/api/threads/thread-1/artifacts/list")
assert response.status_code == 200
payload = response.json()
assert payload["count"] == 3
by_path = {item["virtual_path"]: item for item in payload["files"]}
assert "/mnt/user-data/outputs/notes.md" in by_path
assert "/mnt/user-data/outputs/figures/plot.png" in by_path
assert "/mnt/user-data/uploads/dataset.csv" in by_path
assert "/mnt/user-data/uploads/skill/internal.txt" not in by_path
assert by_path["/mnt/user-data/outputs/notes.md"]["source"] == "artifact"
assert by_path["/mnt/user-data/uploads/dataset.csv"]["source"] == "upload"

View File

@ -147,7 +147,8 @@ def test_create_summarization_middleware_uses_configured_model_alias(monkeypatch
)
captured: dict[str, object] = {}
fake_model = object()
fake_model = MagicMock()
fake_model._llm_type = "test-chat"
def _fake_create_chat_model(*, name=None, thinking_enabled, reasoning_effort=None):
captured["name"] = name
@ -156,10 +157,20 @@ def test_create_summarization_middleware_uses_configured_model_alias(monkeypatch
return fake_model
monkeypatch.setattr(lead_agent_module, "create_chat_model", _fake_create_chat_model)
monkeypatch.setattr(lead_agent_module, "SummarizationMiddleware", lambda **kwargs: kwargs)
middleware = lead_agent_module._create_summarization_middleware()
assert captured["name"] == "model-masswork"
assert captured["thinking_enabled"] is False
assert middleware["model"] is fake_model
assert isinstance(middleware, lead_agent_module.DeerFlowSummarizationMiddleware)
assert middleware.model is fake_model
def test_deerflow_summarization_middleware_uses_chinese_summary_title():
middleware = lead_agent_module.DeerFlowSummarizationMiddleware(
model=MagicMock(),
trigger=("messages", 2),
)
messages = middleware._build_new_messages("旧上下文")
assert messages[0].content == "以下是目前对话的摘要:\n\n旧上下文"

View File

@ -0,0 +1,66 @@
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

@ -123,8 +123,8 @@ services:
command: sh -c "cd backend && uv sync && PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload --reload-include='*.yaml .env' > /app/logs/gateway.log 2>&1"
ports:
# Expose to host so DooD-started sandbox containers can reach the gateway
# via host.docker.internal:8001
- "8001:8001"
# via host.docker.internal:8101
- "8101:8001"
volumes:
- ../backend/:/app/backend/
# Preserve the .venv built during Docker image build — mounting the full backend/

View File

@ -71,8 +71,8 @@ services:
command: sh -c "cd backend && PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --workers 2"
ports:
# Expose gateway port for direct access (e.g. for API clients or testing tools like Postman).
# via host.docker.internal:8001
- "8001:8001"
# via host.docker.internal:8101
- "8101:8001"
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro

View File

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

View File

@ -32,6 +32,7 @@ import { Tooltip } from "@/components/workspace/tooltip";
import { useSpecificChatMode } from "@/components/workspace/use-chat-mode";
import { Welcome } from "@/components/workspace/welcome";
import { getAPIClient } from "@/core/api";
import { sanitizeArtifactPaths } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { useNotification } from "@/core/notification/hooks";
@ -96,7 +97,7 @@ export default function ChatPage() {
sloganIndex % motivationSlogans.length
] ?? {
text: t.chatPage.defaultSlogan,
color: "var(--color-ws-333333)",
color: "var(--color-ws-fg-primary)",
};
const tickerCharacterList = useMemo(() => {
const seen = new Set<string>();
@ -210,6 +211,10 @@ export default function ChatPage() {
const result = thread.values?.title ?? "";
return result === "Untitled" ? "" : result;
}, [thread.values?.title]);
const sanitizedArtifacts = useMemo(
() => sanitizeArtifactPaths(thread.values.artifacts),
[thread.values.artifacts],
);
const [hasSubmitted, setHasSubmitted] = useState(false);
const [historyCutoff, setHistoryCutoff] = useState<number | null>(null);
@ -258,21 +263,21 @@ export default function ChatPage() {
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
useEffect(() => {
setArtifacts(thread.values.artifacts);
setArtifacts(sanitizedArtifacts);
if (
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
autoSelectFirstArtifact
) {
if (thread?.values?.artifacts?.length > 0) {
if (sanitizedArtifacts.length > 0) {
setAutoSelectFirstArtifact(false);
selectArtifact(thread.values.artifacts[0]!);
selectArtifact(sanitizedArtifacts[0]!);
}
}
}, [
autoSelectFirstArtifact,
sanitizedArtifacts,
selectArtifact,
setArtifacts,
thread.values.artifacts,
]);
const artifactPanelOpen = useMemo(() => {
@ -357,7 +362,7 @@ export default function ChatPage() {
<Button
size="sm"
variant="ghost"
className="px-[10px] py-[5px] text-sm font-medium text-ws-150033 hover:text-ws-150033/80"
className="px-[10px] py-[5px] text-sm font-medium text-ws-base-1 hover:text-ws-base-1/80"
disabled={isStreaming}
onClick={() => setShowExitDialog(true)}
>
@ -370,7 +375,7 @@ export default function ChatPage() {
>
<path
d="M3.5 10H13.25H15.6875H16.5M3.5 10L7.5625 6M3.5 10L7.5625 14"
className="text-ws-667085"
className="text-ws-text-muted"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
@ -380,7 +385,7 @@ export default function ChatPage() {
</Button>
</div>
<div
className="flex items-center justify-center overflow-hidden text-sm font-bold font-medium whitespace-nowrap text-ws-333333"
className="flex items-center justify-center overflow-hidden text-sm font-bold font-medium whitespace-nowrap text-ws-fg-primary"
style={{
color: currentSlogan.color,
}}
@ -400,7 +405,7 @@ export default function ChatPage() {
<div className="flex items-center justify-end gap-2 overflow-hidden">
{/* 取消TodoList */}
{/* <DevTodoList
className="bg-ws-ffffff"
className="bg-ws-surface-base"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
@ -409,7 +414,7 @@ export default function ChatPage() {
<Button
size="sm"
variant="ghost"
className="h-full px-[10px] py-[5px] text-sm font-medium text-ws-150033 hover:text-ws-150033"
className="h-full px-[10px] py-[5px] text-sm font-medium text-ws-base-1 hover:text-ws-base-1"
>
<ListTodoIcon className="size-4" /> To-dos
</Button>
@ -420,7 +425,7 @@ export default function ChatPage() {
<Tooltip content={t.chatPage.viewArtifactsTooltip}>
<Button
data-testid="artifacts-open-button"
className="text-ws-150033 hover:text-ws-150033/80"
className="text-ws-base-1 hover:text-ws-base-1/80"
variant="ghost"
onClick={() => {
setArtifactsOpen(true);
@ -438,7 +443,7 @@ export default function ChatPage() {
className={cn(
"flex min-h-0 max-w-full grow flex-col",
showWelcomeStyle && !hasSubmitted
? "bg-ws-ffffff"
? "bg-ws-surface-base"
: "bg-background",
)}
>
@ -492,7 +497,7 @@ export default function ChatPage() {
) : (
<div className="relative flex size-full justify-center px-[20px]">
<div className="z-30"></div>
{thread.values.artifacts?.length === 0 ? (
{sanitizedArtifacts.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title={t.chatPage.noArtifactSelectedTitle}
@ -501,7 +506,7 @@ export default function ChatPage() {
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center">
<header className="flex shrink-0 items-center justify-between border-b">
<h2 className="h-[58px] text-sm leading-[58px] font-bold text-ws-333333">
<h2 className="h-[58px] text-sm leading-[58px] font-bold text-ws-fg-primary">
<span>{t.common.artifacts}</span>
</h2>
<Button
@ -518,7 +523,7 @@ export default function ChatPage() {
<main className="min-h-0 grow overflow-auto">
<ArtifactFileList
className="mb-[207px] max-w-(--container-width-sm) pt-[20px]"
files={thread.values.artifacts ?? []}
files={sanitizedArtifacts}
threadId={threadId}
/>
</main>
@ -549,7 +554,7 @@ export default function ChatPage() {
{!(showWelcomeStyle && thread.isThreadLoading) ? (
<>
<InputBox
className={cn("w-full rounded-[20px] bg-ws-fbfafc")}
className={cn("w-full rounded-[20px] bg-ws-surface-elevated")}
threadId={threadId}
showWelcomeStyle={showWelcomeStyle}
hasSubmitted={hasSubmitted}
@ -609,14 +614,14 @@ export default function ChatPage() {
</p>
<DevDialogFooter>
<Button
className="w-full bg-ws-f9f8fa hover:bg-ws-8e47f0 hover:text-primary-foreground"
className="w-full bg-ws-surface-subtle hover:bg-ws-interactive-primary hover:text-primary-foreground"
variant="ghost"
onClick={() => setShowExitDialog(false)}
>
{t.common.cancel}
</Button>
<Button
className="w-full bg-ws-f9f8fa hover:bg-ws-8e47f0 hover:text-primary-foreground"
className="w-full bg-ws-surface-subtle hover:bg-ws-interactive-primary hover:text-primary-foreground"
variant="ghost"
onClick={async () => {
// 如果正在生成,先终止再退出
@ -665,7 +670,7 @@ export default function ChatPage() {
</p>
<DevDialogFooter singleColumn>
<Button
className="w-full bg-ws-f9f8fa hover:bg-ws-8e47f0 hover:text-primary-foreground"
className="w-full bg-ws-surface-subtle hover:bg-ws-interactive-primary hover:text-primary-foreground"
variant="ghost"
onClick={clearSelectedSkillError}
>

View File

@ -130,7 +130,7 @@ export default function WorkspaceLayout({
/* 灰色圆角矩形容器 */
"rounded-[20px] border-none",
/* 浅灰色背景 + 轻微透明 */
"bg-ws-999999! backdrop-blur-sm",
"bg-ws-overlay-neutral! backdrop-blur-sm",
/* 阴影极轻 */
"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",
from === "user"
? cn("is-user ml-auto justify-end", !isFirstInSession && "mt-6")
: "is-assistant rounded-[10px] bg-ws-ffffff p-4",
: "is-assistant rounded-[10px] bg-ws-surface-base p-4",
className,
)}
{...props}

View File

@ -352,7 +352,7 @@ export function PromptInputAttachment({
{/* 删除按钮 - 右上角 */}
<button
aria-label={t.common.removeAttachment}
className="absolute top-1.5 right-1.5 z-10 flex size-4 cursor-pointer items-center justify-center rounded-sm transition-colors hover:bg-ws-ffffff/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-surface-base/20"
onClick={(e) => {
e.stopPropagation();
if (onRemove) {
@ -397,7 +397,7 @@ export function PromptInputAttachment({
{/* 关闭按钮 - 右上角 */}
<button
aria-label={t.common.removeAttachment}
className="absolute top-1 right-1 z-10 flex size-5 cursor-pointer items-center justify-center rounded bg-ws-ffffff/90 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-ws-ffffff 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-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"
onClick={(e) => {
e.stopPropagation();
if (onRemove) {

View File

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

View File

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

View File

@ -309,7 +309,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col",
"relative flex w-full flex-1 flex-col bg-ws-surface-base",
"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,
)}

View File

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

View File

@ -104,7 +104,7 @@ export function ArtifactFileList({
<div className="absolute top-5 left-4">
{getFileIcon(
file,
"size-9 stroke-1 text-ws-333333 stroke-current",
"size-9 stroke-1 text-ws-fg-primary stroke-current",
)}
</div>
<CardDescription className="pl-10 text-xs">
@ -137,7 +137,7 @@ export function ArtifactFileList({
>
<Button
variant="ghost"
className="text-muted-foreground h-full! hover:bg-transparent! hover:text-ws-333333!"
className="text-muted-foreground h-full! hover:bg-transparent! hover:text-ws-fg-primary!"
>
<DownloadIcon className="size-4" />
{t.common.download}

View File

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

View File

@ -34,7 +34,7 @@ export function DevTodoList({
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent
className={cn(
"z-[100] rounded-[20px] bg-ws-ffffff p-5 shadow-[0_0_20px_0_rgba(0,0,0,0.20)]",
"z-[100] rounded-[20px] bg-ws-surface-base p-5 shadow-[0_0_20px_0_rgba(0,0,0,0.20)]",
className,
)}
align="start"

View File

@ -157,7 +157,7 @@ export function IframeTestPanel() {
<div
ref={panelRef}
className={cn(
"fixed z-[9999] w-72 rounded-xl border border-violet-200 bg-ws-ffffff/95 shadow-2xl backdrop-blur-sm",
"fixed z-[9999] w-72 rounded-xl border border-violet-200 bg-ws-surface-base/95 shadow-2xl backdrop-blur-sm",
position ? "top-0 left-0" : "bottom-24 left-3",
)}
style={position ? { left: position.x, top: position.y } : undefined}

View File

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

View File

@ -1,6 +1,6 @@
"use client";
import { CheckIcon, CopyIcon } from "lucide-react";
import { CheckIcon, CopyIcon, DownloadIcon } from "lucide-react";
import { useCallback, useMemo, useState, type MouseEvent } from "react";
import type {
AnchorHTMLAttributes,
@ -56,27 +56,57 @@ function toMarkdownTable(data: TableData): string {
return [headerLine, dividerLine, ...rowLines].join("\n");
}
function escapeCsvCell(value: string): string {
if (!/[",\n\r]/.test(value)) return value;
return `"${value.replaceAll('"', '""')}"`;
}
function toCsvTable(data: TableData): string {
if (data.headers.length === 0) return "";
return [data.headers, ...data.rows]
.map((row) => row.map(escapeCsvCell).join(","))
.join("\n");
}
function downloadCsvFile(content: string, filename: string) {
const blob = new Blob(["\uFEFF", content], {
type: "text/csv;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
function MarkdownTable({
className,
children,
isLoading,
copyLabel,
downloadLabel,
...props
}: ComponentPropsWithoutRef<"table"> & {
isLoading: boolean;
copyLabel: string;
downloadLabel: string;
}) {
const [copied, setCopied] = useState(false);
const getTableData = useCallback((event: MouseEvent<HTMLButtonElement>) => {
const wrapper = event.currentTarget.closest(
'[data-streamdown="table-wrapper"]',
);
const table = wrapper?.querySelector("table");
if (!(table instanceof HTMLTableElement)) return null;
return parseTableData(table);
}, []);
const handleCopy = useCallback(
async (event: MouseEvent<HTMLButtonElement>) => {
const wrapper = event.currentTarget.closest(
'[data-streamdown="table-wrapper"]',
);
const table = wrapper?.querySelector("table");
if (!(table instanceof HTMLTableElement)) return;
const data = getTableData(event);
if (!data) return;
const markdown = toMarkdownTable(parseTableData(table));
const markdown = toMarkdownTable(data);
if (!markdown) return;
try {
@ -87,7 +117,20 @@ function MarkdownTable({
// no-op
}
},
[],
[getTableData],
);
const handleDownload = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
const data = getTableData(event);
if (!data) return;
const csv = toCsvTable(data);
if (!csv) return;
downloadCsvFile(csv, "table.csv");
},
[getTableData],
);
return (
@ -97,14 +140,21 @@ function MarkdownTable({
>
<div className="flex items-center justify-end gap-1">
<button
className="text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all disabled:cursor-not-allowed disabled:opacity-50"
disabled={isLoading}
className="text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all"
onClick={handleCopy}
title={copyLabel}
type="button"
>
{copied ? <CheckIcon size={14} /> : <CopyIcon size={14} />}
</button>
<button
className="text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all"
onClick={handleDownload}
title={downloadLabel}
type="button"
>
<DownloadIcon size={14} />
</button>
</div>
<div className="overflow-x-auto">
<table
@ -165,7 +215,7 @@ export function MarkdownContent({
<MarkdownTable
className={className}
copyLabel={t.clipboard.copyToClipboard}
isLoading={isLoading}
downloadLabel={t.common.download}
{...props}
>
{children}
@ -173,7 +223,12 @@ export function MarkdownContent({
),
...componentsFromProps,
};
}, [componentsFromProps, isLoading, t.clipboard.copyToClipboard]);
}, [
componentsFromProps,
isLoading,
t.clipboard.copyToClipboard,
t.common.download,
]);
if (!content) return null;

View File

@ -114,7 +114,7 @@ export function MessageGroup({
);
return (
<ChainOfThought
className={cn("w-full gap-2 rounded-lg bg-ws-ffffff", className)}
className={cn("w-full gap-2 rounded-lg bg-ws-surface-base", className)}
open={true}
>
{aboveLastToolCallSteps.length > 0 && (

View File

@ -27,8 +27,11 @@ import {
import { resolveArtifactURL } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import {
extractSummaryTemplateBody,
extractContentFromMessage,
normalizeHumanMessageDisplayText,
extractReasoningContentFromMessage,
isSummaryTemplateMessage,
parseUploadedFiles,
stripPriorityHintSuffix,
stripUploadedFilesTag,
@ -139,6 +142,7 @@ function MessageContent_({
isLoading?: boolean;
threadId: string;
}) {
const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const isHuman = message.type === "human";
const components = useMemo(
@ -167,12 +171,23 @@ function MessageContent_({
const contentToDisplay = useMemo(() => {
if (isHuman) {
return rawContent
? stripPriorityHintSuffix(stripUploadedFilesTag(rawContent))
: "";
if (!rawContent) {
return "";
}
const cleaned = stripPriorityHintSuffix(stripUploadedFilesTag(rawContent));
return normalizeHumanMessageDisplayText(cleaned);
}
return rawContent ?? "";
}, [rawContent, isHuman]);
const isSummaryMessage = useMemo(
() => isHuman && isSummaryTemplateMessage(message),
[isHuman, message],
);
const summaryBody = useMemo(
() => (isSummaryMessage ? extractSummaryTemplateBody(message) : ""),
[isSummaryMessage, message],
);
const [isSummaryExpanded, setIsSummaryExpanded] = useState(false);
const filesList =
files && files.length > 0 && threadId ? (
@ -208,6 +223,7 @@ function MessageContent_({
}
if (isHuman) {
const shouldRenderSummaryCollapse = isSummaryMessage && summaryBody;
const messageResponse = contentToDisplay ? (
<AIElementMessageResponse
remarkPlugins={humanMessagePlugins.remarkPlugins}
@ -220,8 +236,37 @@ function MessageContent_({
return (
<div className={cn("ml-auto flex flex-col gap-2", className)}>
{filesList}
{shouldRenderSummaryCollapse && (
<details
className="w-fit max-w-full rounded-lg border"
open={isSummaryExpanded}
onToggle={(event) => {
setIsSummaryExpanded(event.currentTarget.open);
}}
>
<summary className="text-muted-foreground cursor-pointer px-3 py-2 text-xs select-none">
{isSummaryExpanded
? t.toolCalls.collapseContent
: t.toolCalls.expandContent}
</summary>
<AIElementMessageContent className="w-fit border-t">
<AIElementMessageResponse
remarkPlugins={humanMessagePlugins.remarkPlugins}
rehypePlugins={humanMessagePlugins.rehypePlugins}
components={components}
>
{summaryBody}
</AIElementMessageResponse>
</AIElementMessageContent>
</details>
)}
{messageResponse && (
<AIElementMessageContent className="w-fit">
<AIElementMessageContent
className={cn(
"w-fit",
shouldRenderSummaryCollapse ? "hidden" : undefined,
)}
>
{messageResponse}
</AIElementMessageContent>
)}

View File

@ -225,7 +225,7 @@ export function MessageList({
{showScrollToBottomButton && (
<ConversationScrollButton
className={cn(
"z-20 rounded-full border bg-ws-ffffff/90 shadow-sm backdrop-blur-sm",
"z-20 rounded-full border bg-ws-surface-base/90 shadow-sm backdrop-blur-sm",
scrollButtonClassName,
)}
title={t.chats.scrollToBottom}

View File

@ -157,7 +157,7 @@ function ThemePreviewCard({
"relative overflow-hidden rounded-md border text-xs transition-colors",
previewMode === "dark"
? "border-neutral-800 bg-neutral-900 text-neutral-200"
: "border-slate-200 bg-ws-ffffff text-slate-900",
: "border-slate-200 bg-ws-surface-base text-slate-900",
)}
>
<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
className={cn(
dotSize,
"animate-bouncing rounded-full bg-ws-a3a1a1 opacity-100",
"animate-bouncing rounded-full bg-ws-icon-muted opacity-100",
)}
/>
<div
className={cn(
dotSize,
"animate-bouncing rounded-full bg-ws-a3a1a1 opacity-100 [animation-delay:0.2s]",
"animate-bouncing rounded-full bg-ws-icon-muted opacity-100 [animation-delay:0.2s]",
)}
/>
<div
className={cn(
dotSize,
"animate-bouncing rounded-full bg-ws-a3a1a1 opacity-100 [animation-delay:0.4s]",
"animate-bouncing rounded-full bg-ws-icon-muted opacity-100 [animation-delay:0.4s]",
)}
/>
</div>

View File

@ -39,7 +39,7 @@ export function TodoList({
return (
<div
className={cn(
"flex h-fit w-full origin-bottom translate-y-4 flex-col overflow-hidden rounded-t-xl border border-b-0 bg-ws-ffffff 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-surface-base backdrop-blur-sm transition-all duration-200 ease-out",
hidden ? "pointer-events-none translate-y-8 opacity-0" : "",
className,
)}

View File

@ -43,7 +43,7 @@ export function WorkspaceHeader({ className }: { className?: string }) {
) : (
<div className="text-primary ml-2 cursor-default font-serif">
{/* TODO: 测试标识 */}
XClaw <span className="text-sm text-ws-000000c5">v3.2.8</span>
XClaw <span className="text-sm text-ws-text-subtle-strong">v3.2.8</span>
</div>
)}
<SidebarTrigger />

View File

@ -0,0 +1,38 @@
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),
refetchInterval: 5000,
refetchOnWindowFocus: true,
});
}

View File

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

View File

@ -134,13 +134,13 @@ export const zhCN: Translations = {
suggestion: "GPT-Image-2",
prompt: "编写[项目/功能]的需求文档,包含功能描述、用户故事和验收标准。",
icon: CompassIcon,
children: [{ id: "6107", name: "GPT-Image-2" }],
children: [{ id: "6130", name: "GPT-Image-2" }],
},
{
suggestion: "音乐生成",
prompt: "编写[产品/功能]的使用指南,包含操作步骤、注意事项和常见问题。",
icon: GraduationCapIcon,
children: [{ id: "6126", name: "旋律制造机" }],
children: [{ id: "6133", name: "音乐生成器" }],
},
{
suggestion: "excel数据处理",

View File

@ -26,6 +26,47 @@ type MessageGroup =
| AssistantClarificationGroup
| AssistantSubagentGroup;
const SUMMARY_MESSAGE_TITLES = [
"Here is a summary of the conversation to date",
"以下是目前对话的摘要",
];
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function getSummaryTemplateTitle(content: string) {
return (
SUMMARY_MESSAGE_TITLES.find((title) => {
const titlePattern = new RegExp(
`^\\s*${escapeRegExp(title)}\\s*[:]?(?:\\n|$)`,
"i",
);
return titlePattern.test(content);
}) ?? null
);
}
export function isSummaryTemplateMessage(message: Message) {
if (message.type !== "human") {
return false;
}
return getSummaryTemplateTitle(extractTextFromMessage(message)) !== null;
}
export function extractSummaryTemplateBody(message: Message) {
const content = extractTextFromMessage(message);
const title = getSummaryTemplateTitle(content);
if (!title) {
return content;
}
const titlePrefixPattern = new RegExp(
`^\\s*${escapeRegExp(title)}\\s*[:]?\\s*\\n*`,
"i",
);
return content.replace(titlePrefixPattern, "").trim();
}
export function groupMessages<T>(
messages: Message[],
mapper: (group: MessageGroup) => T,
@ -57,6 +98,9 @@ export function groupMessages<T>(
}
if (message.type === "human") {
// if (isSummaryTemplateMessage(message)) {
// continue;
// }
groups.push({ id: message.id, type: "human", messages: [message] });
continue;
}
@ -364,6 +408,20 @@ export function stripPriorityHintSuffix(content: string): string {
.trim();
}
/**
* Normalize human-authored message text for markdown rendering.
* - Decode literal "\n" into real line breaks.
* - Split Chinese-numbered items (e.g. "1...") into separate paragraphs.
*/
export function normalizeHumanMessageDisplayText(content: string): string {
return content
.replace(/\\n/g, "\n")
.replace(/\r\n?/g, "\n")
.replace(/\n(?=\d+[)]\s*)/g, "\n\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
export function parseUploadedFiles(content: string): FileInMessage[] {
// Match <uploaded_files>...</uploaded_files> tag
const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/;

View File

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

View File

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