diff --git a/backend/api/server.py b/backend/api/server.py index 4bd6bf2..88db646 100644 --- a/backend/api/server.py +++ b/backend/api/server.py @@ -8,9 +8,12 @@ from __future__ import annotations import asyncio import json +import re +import time from pathlib import Path from threading import Lock from typing import Any +from urllib import error, request from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse, JSONResponse @@ -56,15 +59,42 @@ class AgentState: self.cwd = cwd.resolve() self.session_directory = session_directory self._lock = Lock() - self._agent: LocalCodingAgent | None = None + self._agents: dict[str, LocalCodingAgent] = {} self.model = model self.base_url = base_url self.api_key = api_key self.allow_shell = allow_shell self.allow_write = allow_write - self._build_agent() - def _build_agent(self) -> None: + def _account_base(self, account_id: str | None) -> Path: + if not account_id: + return self.session_directory.parent + safe_id = _safe_account_id(account_id) + return (self.session_directory.parent / 'accounts' / safe_id).resolve() + + def account_paths(self, account_id: str | None) -> dict[str, Path]: + if not account_id: + base = self.session_directory.parent + return { + 'base': base, + 'sessions': self.session_directory, + 'scratchpad': self.session_directory, + 'uploads': self.session_directory, + 'outputs': self.session_directory, + } + base = self._account_base(account_id) + return { + 'base': base, + 'sessions': base / 'sessions', + 'scratchpad': base / 'sessions', + 'uploads': base / 'sessions', + 'outputs': base / 'sessions', + } + + def _build_agent(self, account_id: str | None = None) -> LocalCodingAgent: + paths = self.account_paths(account_id) + for directory in paths.values(): + directory.mkdir(parents=True, exist_ok=True) permissions = AgentPermissions( allow_file_write=self.allow_write, allow_shell_commands=self.allow_shell, @@ -72,22 +102,26 @@ class AgentState: runtime_config = AgentRuntimeConfig( cwd=self.cwd, permissions=permissions, - session_directory=self.session_directory, + session_directory=paths['sessions'], + scratchpad_root=paths['scratchpad'], ) model_config = ModelConfig( model=self.model, base_url=self.base_url, api_key=self.api_key, ) - self._agent = LocalCodingAgent( + return LocalCodingAgent( model_config=model_config, runtime_config=runtime_config, ) - @property - def agent(self) -> LocalCodingAgent: - assert self._agent is not None - return self._agent + def agent_for(self, account_id: str | None = None) -> LocalCodingAgent: + key = _safe_account_id(account_id) if account_id else '__default__' + agent = self._agents.get(key) + if agent is None: + agent = self._build_agent(account_id) + self._agents[key] = agent + return agent def update( self, @@ -98,10 +132,13 @@ class AgentState: cwd: str | None = None, allow_shell: bool | None = None, allow_write: bool | None = None, + account_id: str | None = None, ) -> None: with self._lock: + # account_id 只用于让调用方拿到对应账号的 snapshot,不参与全局配置更新。 + del account_id if model is not None: - self.model = model + self.model = _normalize_chat_model_name(model) if base_url is not None: self.base_url = base_url if api_key is not None: @@ -115,17 +152,23 @@ class AgentState: self.allow_shell = allow_shell if allow_write is not None: self.allow_write = allow_write - self._build_agent() + self._agents.clear() - def snapshot(self) -> dict[str, Any]: + def snapshot(self, account_id: str | None = None) -> dict[str, Any]: + agent = self.agent_for(account_id) + paths = self.account_paths(account_id) return { 'model': self.model, 'base_url': self.base_url, 'cwd': str(self.cwd), - 'session_directory': str(self.session_directory), + 'account_id': _safe_account_id(account_id) if account_id else None, + 'account_directory': str(paths['base']), + 'session_directory': str(paths['sessions']), + 'upload_directory': str(paths['uploads']), + 'output_directory': str(paths['outputs']), 'allow_shell': self.allow_shell, 'allow_write': self.allow_write, - 'active_session_id': self.agent.active_session_id, + 'active_session_id': agent.active_session_id, } def lock(self) -> Lock: @@ -138,7 +181,10 @@ class AgentState: class ChatRequest(BaseModel): prompt: str = Field(min_length=1) + runtime_context: str | None = None resume_session_id: str | None = None + account_id: str | None = None + session_id: str | None = None class StateUpdate(BaseModel): @@ -148,6 +194,12 @@ class StateUpdate(BaseModel): cwd: str | None = None allow_shell: bool | None = None allow_write: bool | None = None + account_id: str | None = None + + +class ModelListRequest(BaseModel): + base_url: str | None = None + api_key: str | None = None # --------------------------------------------------------------------------- @@ -170,8 +222,8 @@ def create_app(state: AgentState) -> FastAPI: # ------------- info ------------------------------------------------------ @app.get('/api/state') - async def get_state() -> dict[str, Any]: - return state.snapshot() + async def get_state(account_id: str | None = None) -> dict[str, Any]: + return state.snapshot(account_id) @app.post('/api/state') async def post_state(payload: StateUpdate) -> dict[str, Any]: @@ -179,7 +231,7 @@ def create_app(state: AgentState) -> FastAPI: state.update(**payload.model_dump(exclude_none=True)) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) - return state.snapshot() + return state.snapshot(payload.account_id) @app.get('/api/slash-commands') async def list_slash_commands() -> list[dict[str, Any]]: @@ -208,45 +260,62 @@ def create_app(state: AgentState) -> FastAPI: if skill.user_invocable ] + @app.get('/api/models') + async def list_models_get() -> dict[str, Any]: + return _list_backend_models(state.base_url, state.api_key) + + @app.post('/api/models') + async def list_models_post(payload: ModelListRequest) -> dict[str, Any]: + return _list_backend_models( + payload.base_url or state.base_url, + payload.api_key or state.api_key, + ) + # ------------- sessions -------------------------------------------------- @app.get('/api/sessions') - async def list_sessions() -> list[dict[str, Any]]: - directory = state.session_directory + async def list_sessions(account_id: str | None = None) -> list[dict[str, Any]]: + directory = state.account_paths(account_id)['sessions'] if not directory.exists(): return [] results: list[dict[str, Any]] = [] - for path in sorted( - directory.glob('*.json'), - key=lambda p: p.stat().st_mtime, - reverse=True, - ): + for path in sorted(_iter_session_files(directory), key=_session_mtime, reverse=True): try: data = json.loads(path.read_text(encoding='utf-8')) except (OSError, json.JSONDecodeError): continue + session_id = str(data.get('session_id', _session_id_from_path(path))) + usage = data.get('usage') if isinstance(data.get('usage'), dict) else {} + model_config = ( + data.get('model_config') + if isinstance(data.get('model_config'), dict) + else {} + ) messages = data.get('messages') or [] preview = '' for msg in messages: if isinstance(msg, dict) and msg.get('role') == 'user': content = msg.get('content', '') - if isinstance(content, str): - preview = content[:120] + if isinstance(content, str) and not _is_internal_message(content): + preview = _strip_session_context(content)[:120] break results.append( { - 'session_id': data.get('session_id', path.stem), + 'session_id': session_id, 'turns': data.get('turns', 0), 'tool_calls': data.get('tool_calls', 0), 'preview': preview, - 'modified_at': path.stat().st_mtime, + 'modified_at': _session_mtime(path), + 'model': model_config.get('model'), + 'usage': usage, } ) return results @app.get('/api/sessions/{session_id}') - async def get_session(session_id: str) -> dict[str, Any]: + async def get_session(session_id: str, account_id: str | None = None) -> dict[str, Any]: + directory = state.account_paths(account_id)['sessions'] try: - stored = load_agent_session(session_id, directory=state.session_directory) + stored = load_agent_session(session_id, directory=directory) except FileNotFoundError: raise HTTPException(status_code=404, detail='Session not found') return _serialize_stored_session(stored) @@ -260,22 +329,42 @@ def create_app(state: AgentState) -> FastAPI: def _run() -> dict[str, Any]: with state.lock(): - agent = state.agent + agent = state.agent_for(request.account_id) + session_directory = state.account_paths(request.account_id)['sessions'] + started_at = time.perf_counter() if request.resume_session_id is not None: try: stored = load_agent_session( request.resume_session_id, - directory=state.session_directory, + directory=session_directory, ) except FileNotFoundError: raise HTTPException( status_code=404, detail='Session to resume not found', ) - result = agent.resume(prompt, stored) + result = agent.resume( + prompt, + stored, + runtime_context=request.runtime_context, + ) else: - result = agent.run(prompt) - return _serialize_run_result(result) + result = agent.run( + prompt, + session_id=_safe_session_id(request.session_id), + runtime_context=request.runtime_context, + ) + elapsed_ms = max(0, int((time.perf_counter() - started_at) * 1000)) + payload = _serialize_run_result(result) + payload['elapsed_ms'] = elapsed_ms + if result.session_id: + _annotate_last_assistant_elapsed( + session_directory, + result.session_id, + elapsed_ms, + ) + _annotate_transcript_elapsed(payload, elapsed_ms) + return payload try: payload = await asyncio.to_thread(_run) @@ -292,10 +381,10 @@ def create_app(state: AgentState) -> FastAPI: return payload @app.post('/api/clear') - async def clear_state() -> dict[str, Any]: + async def clear_state(account_id: str | None = None) -> dict[str, Any]: with state.lock(): - state.agent.clear_runtime_state() - return state.snapshot() + state.agent_for(account_id).clear_runtime_state() + return state.snapshot(account_id) return app @@ -338,3 +427,182 @@ def _serialize_stored_session(stored: StoredAgentSession) -> dict[str, Any]: 'total_cost_usd': stored.total_cost_usd, 'model': stored.model_config.get('model'), } + + +def _list_backend_models(base_url: str, api_key: str) -> dict[str, Any]: + req = request.Request( + _join_url(base_url, '/models'), + headers={ + 'Authorization': f'Bearer {api_key}', + 'Content-Type': 'application/json', + }, + method='GET', + ) + try: + with request.urlopen(req, timeout=10) as response: + payload = json.loads(response.read().decode('utf-8')) + except error.HTTPError as exc: + detail = exc.read().decode('utf-8', errors='replace') + raise HTTPException( + status_code=502, + detail=f'HTTP {exc.code} from model backend: {detail}', + ) from exc + except (error.URLError, OSError, json.JSONDecodeError) as exc: + raise HTTPException( + status_code=502, + detail=f'Unable to list models from {base_url}: {exc}', + ) from exc + + models = _normalize_model_list(payload) + return {'models': models, 'raw_count': len(models)} + + +def _normalize_model_list(payload: Any) -> list[dict[str, str]]: + data = payload.get('data') if isinstance(payload, dict) else payload + if not isinstance(data, list): + return [] + model_ids: dict[str, str] = {} + for item in data: + if isinstance(item, str): + normalized = _normalize_model_option(item) + if normalized is not None: + model_ids.setdefault(normalized.lower(), normalized) + continue + if not isinstance(item, dict): + continue + model_id = item.get('id') or item.get('model') or item.get('name') + if not isinstance(model_id, str) or not model_id: + continue + normalized = _normalize_model_option(model_id) + if normalized is not None: + model_ids.setdefault(normalized.lower(), normalized) + return [{'id': model_id} for model_id in sorted(model_ids.values(), key=str.lower)] + + +def _normalize_model_option(model_id: str) -> str | None: + normalized = _normalize_chat_model_name(model_id) + lower = normalized.lower() + if lower.startswith('xiaomi/mimo-v2') and not any( + blocked in lower for blocked in ('audio', 'asr', 'tts', 'voiceclone', 'voicedesign') + ): + return normalized + known_chat_models = { + 'xiaomi/deepseek-r1-0528', + 'xiaomi/minimax-m2.5', + 'xiaomi/qwen3-32b', + } + if lower in known_chat_models: + return normalized + return None + + +def _normalize_chat_model_name(model: str) -> str: + value = model.strip() + lower = value.lower() + if lower.startswith('mimo-v2') or lower in { + 'deepseek-r1-0528', + 'minimax-m2.5', + 'qwen3-32b', + }: + return f'xiaomi/{value}' + return value + + +def _join_url(base_url: str, suffix: str) -> str: + return f'{base_url.rstrip("/")}/{suffix.lstrip("/")}' + + +def _annotate_transcript_elapsed(payload: dict[str, Any], elapsed_ms: int) -> None: + transcript = payload.get('transcript') + if not isinstance(transcript, list): + return + for entry in reversed(transcript): + if not isinstance(entry, dict) or entry.get('role') != 'assistant': + continue + metadata = entry.get('metadata') + if not isinstance(metadata, dict): + metadata = {} + entry['metadata'] = metadata + metadata['elapsed_ms'] = elapsed_ms + return + + +def _annotate_last_assistant_elapsed( + directory: Path, + session_id: str, + elapsed_ms: int, +) -> None: + path = directory / session_id / 'session.json' + if not path.exists(): + path = directory / f'{session_id}.json' + try: + data = json.loads(path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + return + messages = data.get('messages') + if not isinstance(messages, list): + return + for message in reversed(messages): + if not isinstance(message, dict) or message.get('role') != 'assistant': + continue + metadata = message.get('metadata') + if not isinstance(metadata, dict): + metadata = {} + message['metadata'] = metadata + metadata['elapsed_ms'] = elapsed_ms + try: + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8') + except OSError: + return + return + + +def _safe_account_id(account_id: str | None) -> str: + normalized = re.sub(r'[^a-zA-Z0-9._-]+', '_', (account_id or '').strip()) + return normalized[:80] or 'default' + + +def _is_internal_message(content: str) -> bool: + return content.lstrip().startswith('') + + +def _strip_session_context(content: str) -> str: + marker = '\n\n[当前会话目录]' + if marker in content: + return content.split(marker, 1)[0].strip() + marker = '\n[当前会话目录]' + if marker in content: + return content.split(marker, 1)[0].strip() + return content.strip() + + +def _iter_session_files(directory: Path) -> list[Path]: + if not directory.exists(): + return [] + by_session_id: dict[str, Path] = {} + for path in directory.glob('*.json'): + by_session_id[_session_id_from_path(path)] = path + for path in directory.glob('*/session.json'): + # 新目录格式包含 input/output/scratchpad,优先级高于旧的平铺 json。 + by_session_id[_session_id_from_path(path)] = path + return list(by_session_id.values()) + + +def _session_mtime(path: Path) -> float: + try: + return path.stat().st_mtime + except OSError: + return 0.0 + + +def _session_id_from_path(path: Path) -> str: + if path.name == 'session.json': + return path.parent.name + return path.stem + + +def _safe_session_id(session_id: str | None) -> str | None: + if not session_id: + return None + normalized = re.sub(r'[^a-zA-Z0-9._-]+', '_', session_id.strip()) + return normalized[:120] or None diff --git a/frontend/app/.codespellignore b/frontend/app/.codespellignore deleted file mode 100644 index 74ae90b..0000000 --- a/frontend/app/.codespellignore +++ /dev/null @@ -1 +0,0 @@ -productionize \ No newline at end of file diff --git a/frontend/app/.dockerignore b/frontend/app/.dockerignore deleted file mode 100644 index d97359f..0000000 --- a/frontend/app/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules -.next -.git -.env \ No newline at end of file diff --git a/frontend/app/.env.example b/frontend/app/.env.example new file mode 100644 index 0000000..547a11e --- /dev/null +++ b/frontend/app/.env.example @@ -0,0 +1,4 @@ +OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# chat persistence -- sign up on cloud.assistant-ui.com +# NEXT_PUBLIC_ASSISTANT_BASE_URL= diff --git a/frontend/app/.gitignore b/frontend/app/.gitignore index 2b60de5..38bf976 100644 --- a/frontend/app/.gitignore +++ b/frontend/app/.gitignore @@ -1,30 +1,45 @@ -# Logs -logs -*.log +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug npm-debug.log* yarn-debug.log* yarn-error.log* -pnpm-debug.log* -lerna-debug.log* +.pnpm-debug.log* -node_modules -dist -dist-ssr -*.local +# env files (can opt-in for committing if needed) +.env* +!.env.example -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? +# vercel +.vercel -# LangGraph API -.langgraph_api -.env -.next/ +# typescript +*.tsbuildinfo next-env.d.ts + +# Turborepo +.turbo diff --git a/frontend/app/.prettierignore b/frontend/app/.prettierignore deleted file mode 100644 index e9e1e3c..0000000 --- a/frontend/app/.prettierignore +++ /dev/null @@ -1,6 +0,0 @@ -# Ignore artifacts: -build -coverage - -# -pnpm-lock.yaml diff --git a/frontend/app/LICENSE.agent-chat-ui b/frontend/app/LICENSE.agent-chat-ui deleted file mode 100644 index 19b767d..0000000 --- a/frontend/app/LICENSE.agent-chat-ui +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Brace Sproul - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/frontend/app/README.md b/frontend/app/README.md index c8ec7f7..63cf4dc 100644 --- a/frontend/app/README.md +++ b/frontend/app/README.md @@ -1,21 +1,27 @@ -# Claw Code Agent Frontend +This frontend is based on the official [assistant-ui](https://github.com/assistant-ui/assistant-ui) starter project. -This frontend is based on the MIT-licensed `langchain-ai/agent-chat-ui` project and will be adapted into the Claw Code Agent data workbench. +The current goal is to keep the upstream assistant-ui visual structure intact, then add Claw Code Agent backend adapters in small, reviewable steps. -Current scope: +## Getting Started -- Keep the upstream chat UI foundation. -- Preserve the artifact side-panel pattern for review and dataset outputs. -- Replace the LangGraph client integration with Claw Code Agent FastAPI endpoints in later steps. +First, add your OpenAI API key to `.env.local` file: -Upstream license attribution is kept in `LICENSE.agent-chat-ui`. - -## Development - -```bash -corepack enable -pnpm install -pnpm dev +``` +OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` -The default Next.js dev server runs on `http://localhost:3000`. +Then, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. diff --git a/frontend/app/app/api/chat/route.ts b/frontend/app/app/api/chat/route.ts new file mode 100644 index 0000000..d9f3f06 --- /dev/null +++ b/frontend/app/app/api/chat/route.ts @@ -0,0 +1,314 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { + createUIMessageStream, + createUIMessageStreamResponse, + type UIMessage, + type UIMessageStreamWriter, +} from "ai"; +import { + accountSessionInputRoot, + accountSessionOutputRoot, + getCurrentAccount, +} from "@/lib/claw-auth"; + +export const runtime = "nodejs"; + +type ClawChatResponse = { + final_output?: string; + session_id?: string; + tool_calls?: number; + stop_reason?: string; + elapsed_ms?: number; + usage?: UsageSummary; + transcript?: ClawTranscriptEntry[]; + error?: string; + detail?: string; +}; + +type ChatRequestBody = { + id?: string; + messages: UIMessage[]; + resumeSessionId?: string; +}; + +type ClawTranscriptEntry = { + role?: string; + content?: string; + tool_call_id?: string; + tool_calls?: ClawToolCall[]; +}; + +type ClawToolCall = { + id?: string; + name?: string; + arguments?: unknown; + function?: { + name?: string; + arguments?: unknown; + }; +}; + +type UsageSummary = { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + reasoning_tokens?: number; +}; + +const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765"; + +export async function POST(req: Request) { + const account = await getCurrentAccount(); + if (!account) return new Response("请先登录账号", { status: 401 }); + + const { id, messages, resumeSessionId }: ChatRequestBody = await req.json(); + const resumeId = resumeSessionId ?? getLastSessionId(messages); + const sessionId = safeSessionId(resumeId ?? id ?? crypto.randomUUID()); + await ensureSessionDirectories(account.id, sessionId); + const userPrompt = await getLastUserText(messages, account.id, sessionId); + + if (!userPrompt) { + return new Response("Prompt is empty", { status: 400 }); + } + const runtimeContext = renderSessionRuntimeContext(account.id, sessionId); + + const stream = createUIMessageStream({ + execute: async ({ writer }) => { + writer.write({ type: "start" }); + writer.write({ type: "start-step" }); + + const startedAt = Date.now(); + writer.write({ type: "reasoning-start", id: "reasoning-1" }); + writer.write({ + type: "reasoning-delta", + id: "reasoning-1", + delta: "思考中,正在判断是否需要调用工具。", + }); + const payload = await callClawBackend( + userPrompt, + runtimeContext, + account.id, + sessionId, + resumeId, + ); + const elapsedMs = payload.elapsed_ms ?? Date.now() - startedAt; + const text = formatClawResponse(payload); + + writer.write({ + type: "reasoning-delta", + id: "reasoning-1", + delta: `\n思考并执行完成,用时 ${formatDuration(elapsedMs)}。`, + }); + writer.write({ type: "reasoning-end", id: "reasoning-1" }); + writeToolTrace(writer, payload.transcript); + writer.write({ type: "text-start", id: "text-1" }); + writer.write({ type: "text-delta", id: "text-1", delta: text }); + writer.write({ type: "text-end", id: "text-1" }); + writer.write({ type: "finish-step" }); + writer.write({ + type: "finish", + finishReason: payload.error ? "error" : "stop", + messageMetadata: { + sessionId: payload.session_id, + toolCalls: payload.tool_calls, + stopReason: payload.stop_reason, + elapsedMs, + usage: payload.usage, + }, + }); + }, + }); + + return createUIMessageStreamResponse({ stream }); +} + +function formatDuration(ms: number) { + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +function writeToolTrace( + writer: UIMessageStreamWriter, + transcript?: ClawTranscriptEntry[], +) { + if (!transcript?.length) return; + + const toolResults = new Map(); + for (const entry of transcript) { + if (entry.role === "tool" && entry.tool_call_id) { + toolResults.set(entry.tool_call_id, entry.content ?? ""); + } + } + + for (const entry of transcript) { + if (!entry.tool_calls?.length) continue; + for (const call of entry.tool_calls) { + const toolCallId = call.id; + const toolName = call.function?.name ?? call.name; + if (!toolCallId || !toolName) continue; + + writer.write({ + type: "tool-input-available", + toolCallId, + toolName, + input: parseToolInput(call.function?.arguments ?? call.arguments), + }); + + if (toolResults.has(toolCallId)) { + writer.write({ + type: "tool-output-available", + toolCallId, + output: toolResults.get(toolCallId), + }); + } + } + } +} + +function parseToolInput(value: unknown) { + if (typeof value !== "string") return value ?? {}; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +async function getLastUserText( + messages: UIMessage[], + accountId: string, + threadId?: string, +) { + const lastUserMessage = [...messages] + .reverse() + .find((msg) => msg.role === "user"); + if (!lastUserMessage) return ""; + + const chunks: string[] = []; + for (const part of lastUserMessage.parts) { + if (part.type === "text") chunks.push(part.text); + if (part.type === "file") { + chunks.push( + await saveFilePart(part, accountId, threadId ?? lastUserMessage.id), + ); + } + } + + return chunks.filter(Boolean).join("\n\n").trim(); +} + +async function ensureSessionDirectories(accountId: string, sessionId: string) { + await Promise.all([ + mkdir(accountSessionInputRoot(accountId, sessionId), { recursive: true }), + mkdir(accountSessionOutputRoot(accountId, sessionId), { recursive: true }), + ]); +} + +function renderSessionRuntimeContext(accountId: string, sessionId: string) { + return [ + "[当前会话目录]", + `- 输入目录: ${accountSessionInputRoot(accountId, sessionId)}`, + `- 输出目录: ${accountSessionOutputRoot(accountId, sessionId)}`, + "如需保存本轮任务产物,请优先写入输出目录。", + ].join("\n\n"); +} + +function getLastSessionId(messages: UIMessage[]) { + for (const message of [...messages].reverse()) { + if (message.role !== "assistant") continue; + const metadata = message.metadata; + if (metadata && typeof metadata === "object" && "sessionId" in metadata) { + const sessionId = (metadata as { sessionId?: unknown }).sessionId; + if (typeof sessionId === "string" && sessionId) return sessionId; + } + } + return undefined; +} + +async function saveFilePart( + part: Extract, + accountId: string, + threadId: string, +) { + const filename = safeFilename(part.filename ?? "attachment"); + if (!part.url.startsWith("data:")) { + return `[文件] ${filename}\n- 类型: ${part.mediaType}\n- URL: ${part.url}`; + } + + const match = part.url.match(/^data:([^;,]+)?(;base64)?,(.*)$/); + if (!match) { + return `[文件] ${filename}\n- 类型: ${part.mediaType}\n- 状态: 无法解析 data URL`; + } + + const isBase64 = Boolean(match[2]); + const body = match[3] ?? ""; + const bytes = isBase64 + ? Buffer.from(body, "base64") + : Buffer.from(decodeURIComponent(body), "utf8"); + const uploadDir = accountSessionInputRoot(accountId, safeFilename(threadId)); + await mkdir(uploadDir, { recursive: true }); + const filePath = path.join(uploadDir, `${Date.now()}-${filename}`); + await writeFile(filePath, bytes); + + return [ + `[文件已上传] ${filename}`, + `- 类型: ${part.mediaType}`, + `- 本地路径: ${filePath}`, + "请在需要读取文件内容时使用 read_file 工具读取该本地路径。", + ].join("\n"); +} + +function safeFilename(value: string) { + return value.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 120) || "attachment"; +} + +async function callClawBackend( + prompt: string, + runtimeContext: string, + accountId: string, + sessionId: string, + resumeSessionId?: string, +): Promise { + try { + const response = await fetch(`${CLAW_API_URL}/api/chat`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + prompt, + runtime_context: runtimeContext, + account_id: accountId, + session_id: sessionId, + ...(resumeSessionId ? { resume_session_id: resumeSessionId } : {}), + }), + }); + const payload = (await response.json()) as ClawChatResponse; + if (!response.ok) { + return { + error: + payload.detail ?? + payload.error ?? + `Claw backend returned ${response.status}`, + }; + } + return payload; + } catch (err) { + return { + error: + err instanceof Error + ? `Unable to reach Claw backend: ${err.message}` + : "Unable to reach Claw backend", + }; + } +} + +function formatClawResponse(payload: ClawChatResponse) { + if (payload.error) return payload.error; + return payload.final_output ?? ""; +} + +function safeSessionId(value: string) { + return ( + value.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 120) || crypto.randomUUID() + ); +} diff --git a/frontend/app/app/api/claw/auth/login/route.ts b/frontend/app/app/api/claw/auth/login/route.ts new file mode 100644 index 0000000..5ad5215 --- /dev/null +++ b/frontend/app/app/api/claw/auth/login/route.ts @@ -0,0 +1,20 @@ +import { loginAccount } from "@/lib/claw-auth"; + +export async function POST(req: Request) { + try { + const payload = (await req.json()) as { + username?: string; + password?: string; + }; + const account = await loginAccount( + payload.username ?? "", + payload.password ?? "", + ); + return Response.json({ account }); + } catch (err) { + return Response.json( + { error: err instanceof Error ? err.message : "登录失败" }, + { status: 400 }, + ); + } +} diff --git a/frontend/app/app/api/claw/auth/logout/route.ts b/frontend/app/app/api/claw/auth/logout/route.ts new file mode 100644 index 0000000..29a29c8 --- /dev/null +++ b/frontend/app/app/api/claw/auth/logout/route.ts @@ -0,0 +1,6 @@ +import { logoutAccount } from "@/lib/claw-auth"; + +export async function POST() { + await logoutAccount(); + return Response.json({ ok: true }); +} diff --git a/frontend/app/app/api/claw/auth/me/route.ts b/frontend/app/app/api/claw/auth/me/route.ts new file mode 100644 index 0000000..ea3879c --- /dev/null +++ b/frontend/app/app/api/claw/auth/me/route.ts @@ -0,0 +1,5 @@ +import { getCurrentAccount } from "@/lib/claw-auth"; + +export async function GET() { + return Response.json({ account: await getCurrentAccount() }); +} diff --git a/frontend/app/app/api/claw/auth/register/route.ts b/frontend/app/app/api/claw/auth/register/route.ts new file mode 100644 index 0000000..cda9186 --- /dev/null +++ b/frontend/app/app/api/claw/auth/register/route.ts @@ -0,0 +1,20 @@ +import { registerAccount } from "@/lib/claw-auth"; + +export async function POST(req: Request) { + try { + const payload = (await req.json()) as { + username?: string; + password?: string; + }; + const account = await registerAccount( + payload.username ?? "", + payload.password ?? "", + ); + return Response.json({ account }); + } catch (err) { + return Response.json( + { error: err instanceof Error ? err.message : "注册失败" }, + { status: 400 }, + ); + } +} diff --git a/frontend/app/app/api/claw/files/route.ts b/frontend/app/app/api/claw/files/route.ts new file mode 100644 index 0000000..57b6265 --- /dev/null +++ b/frontend/app/app/api/claw/files/route.ts @@ -0,0 +1,48 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { accountBaseRoot, getCurrentAccount } from "@/lib/claw-auth"; + +export const runtime = "nodejs"; + +export async function GET(req: Request) { + const account = await getCurrentAccount(); + if (!account) + return Response.json({ error: "请先登录账号" }, { status: 401 }); + + const url = new URL(req.url); + const requestedPath = url.searchParams.get("path"); + if (!requestedPath) { + return Response.json({ error: "缺少文件路径" }, { status: 400 }); + } + + const accountRoot = path.resolve(accountBaseRoot(account.id)); + const filePath = path.resolve(requestedPath); + if (!filePath.startsWith(`${accountRoot}${path.sep}`)) { + return Response.json({ error: "文件不在当前账号目录内" }, { status: 403 }); + } + + try { + const fileStat = await stat(filePath); + if (!fileStat.isFile()) { + return Response.json({ error: "目标不是文件" }, { status: 400 }); + } + const bytes = await readFile(filePath); + return new Response(bytes, { + headers: { + "content-type": contentTypeFor(filePath), + "content-disposition": `attachment; filename="${encodeURIComponent(path.basename(filePath))}"`, + }, + }); + } catch { + return Response.json({ error: "文件不存在" }, { status: 404 }); + } +} + +function contentTypeFor(filePath: string) { + const ext = path.extname(filePath).toLowerCase(); + if (ext === ".json") return "application/json; charset=utf-8"; + if (ext === ".jsonl" || ext === ".txt" || ext === ".md" || ext === ".csv") { + return "text/plain; charset=utf-8"; + } + return "application/octet-stream"; +} diff --git a/frontend/app/app/api/claw/models/route.ts b/frontend/app/app/api/claw/models/route.ts new file mode 100644 index 0000000..689ec6e --- /dev/null +++ b/frontend/app/app/api/claw/models/route.ts @@ -0,0 +1,53 @@ +import { getCurrentAccount } from "@/lib/claw-auth"; + +const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765"; + +type ModelListRequest = { + base_url?: string; + api_key?: string; +}; + +export async function GET() { + return proxyModelsRequest("GET"); +} + +export async function POST(req: Request) { + const payload = (await req.json()) as ModelListRequest; + return proxyModelsRequest("POST", payload); +} + +async function proxyModelsRequest( + method: "GET" | "POST", + payload?: ModelListRequest, +) { + const account = await getCurrentAccount(); + if (!account) return Response.json({ models: [] }, { status: 401 }); + + try { + const response = await fetch(`${CLAW_API_URL}/api/models`, { + method, + headers: payload ? { "content-type": "application/json" } : undefined, + body: payload ? JSON.stringify(payload) : undefined, + cache: "no-store", + }); + const body = await response.text(); + return new Response(body, { + status: response.status, + headers: { + "content-type": + response.headers.get("content-type") ?? "application/json", + }, + }); + } catch (err) { + return Response.json( + { + error: + err instanceof Error + ? `Unable to reach Claw backend: ${err.message}` + : "Unable to reach Claw backend", + models: [], + }, + { status: 502 }, + ); + } +} diff --git a/frontend/app/app/api/claw/sessions/[sessionId]/route.ts b/frontend/app/app/api/claw/sessions/[sessionId]/route.ts new file mode 100644 index 0000000..edc5e01 --- /dev/null +++ b/frontend/app/app/api/claw/sessions/[sessionId]/route.ts @@ -0,0 +1,25 @@ +import { getCurrentAccount } from "@/lib/claw-auth"; + +const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765"; + +export async function GET( + _req: Request, + { params }: { params: Promise<{ sessionId: string }> }, +) { + const account = await getCurrentAccount(); + if (!account) + return Response.json({ error: "请先登录账号" }, { status: 401 }); + + const { sessionId } = await params; + const url = new URL(`${CLAW_API_URL}/api/sessions/${sessionId}`); + url.searchParams.set("account_id", account.id); + const response = await fetch(url, { cache: "no-store" }); + const payload = await response.text(); + return new Response(payload, { + status: response.status, + headers: { + "content-type": + response.headers.get("content-type") ?? "application/json", + }, + }); +} diff --git a/frontend/app/app/api/claw/sessions/route.ts b/frontend/app/app/api/claw/sessions/route.ts new file mode 100644 index 0000000..3f8e820 --- /dev/null +++ b/frontend/app/app/api/claw/sessions/route.ts @@ -0,0 +1,22 @@ +import { getCurrentAccount } from "@/lib/claw-auth"; + +const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765"; + +export async function GET() { + const account = await getCurrentAccount(); + if (!account) return Response.json([], { status: 401 }); + + const url = new URL(`${CLAW_API_URL}/api/sessions`); + url.searchParams.set("account_id", account.id); + const response = await fetch(url, { + cache: "no-store", + }); + const payload = await response.text(); + return new Response(payload, { + status: response.status, + headers: { + "content-type": + response.headers.get("content-type") ?? "application/json", + }, + }); +} diff --git a/frontend/app/app/api/claw/skills/route.ts b/frontend/app/app/api/claw/skills/route.ts new file mode 100644 index 0000000..7d70b15 --- /dev/null +++ b/frontend/app/app/api/claw/skills/route.ts @@ -0,0 +1,20 @@ +import { getCurrentAccount } from "@/lib/claw-auth"; + +const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765"; + +export async function GET() { + const account = await getCurrentAccount(); + if (!account) return Response.json([], { status: 401 }); + + const response = await fetch(`${CLAW_API_URL}/api/skills`, { + cache: "no-store", + }); + const payload = await response.text(); + return new Response(payload, { + status: response.status, + headers: { + "content-type": + response.headers.get("content-type") ?? "application/json", + }, + }); +} diff --git a/frontend/app/app/api/claw/state/route.ts b/frontend/app/app/api/claw/state/route.ts new file mode 100644 index 0000000..43ca242 --- /dev/null +++ b/frontend/app/app/api/claw/state/route.ts @@ -0,0 +1,58 @@ +import { getCurrentAccount } from "@/lib/claw-auth"; + +type ClawState = { + model?: string; + base_url?: string; + api_key?: string; + cwd?: string; + allow_shell?: boolean; + allow_write?: boolean; +}; + +const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765"; + +export async function GET() { + return proxyStateRequest("GET"); +} + +export async function POST(req: Request) { + const payload = (await req.json()) as ClawState; + return proxyStateRequest("POST", payload); +} + +async function proxyStateRequest(method: "GET" | "POST", payload?: ClawState) { + try { + const account = await getCurrentAccount(); + const url = new URL(`${CLAW_API_URL}/api/state`); + if (method === "GET" && account) + url.searchParams.set("account_id", account.id); + const response = await fetch(url, { + method, + headers: payload ? { "content-type": "application/json" } : undefined, + body: payload + ? JSON.stringify({ + ...payload, + ...(account ? { account_id: account.id } : {}), + }) + : undefined, + }); + const body = await response.text(); + return new Response(body, { + status: response.status, + headers: { + "content-type": + response.headers.get("content-type") ?? "application/json", + }, + }); + } catch (err) { + return Response.json( + { + error: + err instanceof Error + ? `Unable to reach Claw backend: ${err.message}` + : "Unable to reach Claw backend", + }, + { status: 502 }, + ); + } +} diff --git a/frontend/app/app/assistant.tsx b/frontend/app/app/assistant.tsx new file mode 100644 index 0000000..2f9062a --- /dev/null +++ b/frontend/app/app/assistant.tsx @@ -0,0 +1,127 @@ +"use client"; + +import type { ExportedMessageRepository } from "@assistant-ui/core"; +import { AssistantRuntimeProvider } from "@assistant-ui/react"; +import { + AssistantChatTransport, + useChatRuntime, +} from "@assistant-ui/react-ai-sdk"; +import type { UIMessage } from "ai"; +import { useCallback, useMemo } from "react"; +import { + ActivityPanel, + ActivityProvider, +} from "@/components/assistant-ui/activity-panel"; +import { Thread } from "@/components/assistant-ui/thread"; +import { ThreadListSidebar } from "@/components/assistant-ui/threadlist-sidebar"; +import { Separator } from "@/components/ui/separator"; +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar"; +import { ClawSessionReplayProvider } from "@/lib/claw-session-replay"; +import { ClawAccountGate, useClawAccount } from "./claw-account-gate"; +import { ClawLlmSettings } from "./claw-llm-settings"; + +export const Assistant = () => { + const { account, isLoading, setAccount } = useClawAccount(); + const transport = useMemo( + () => + new AssistantChatTransport({ + api: "/api/chat", + prepareSendMessagesRequest: async (options) => { + const body = options.body as Record; + const messages = options.messages as UIMessage[]; + const selectedSessionId = + typeof window !== "undefined" + ? window.localStorage.getItem("claw.activeSessionId") + : null; + const lastSessionId = getLastSessionId(messages); + const selectedResumeSessionId = + messages.length > 1 ? selectedSessionId : null; + + return { + body: { + ...body, + id: options.id, + messages, + resumeSessionId: + lastSessionId ?? selectedResumeSessionId ?? undefined, + }, + }; + }, + }), + [], + ); + const runtime = useChatRuntime({ + transport, + }); + const replaySession = useCallback( + (sessionId: string, repository: ExportedMessageRepository) => { + window.localStorage.setItem("claw.activeSessionId", sessionId); + runtime.thread.import(repository); + }, + [runtime], + ); + + if (isLoading) { + return ( +
+ 正在加载账号... +
+ ); + } + + if (!account) { + return ; + } + + return ( + + + + +
+ setAccount(null)} + /> + +
+ + +
+
新会话
+
+ Claw Data Agent +
+
+ +
+
+
+ +
+ +
+
+
+
+
+
+
+ ); +}; + +function getLastSessionId(messages: UIMessage[]) { + for (const message of [...messages].reverse()) { + if (message.role !== "assistant") continue; + const metadata = message.metadata; + if (metadata && typeof metadata === "object" && "sessionId" in metadata) { + const sessionId = (metadata as { sessionId?: unknown }).sessionId; + if (typeof sessionId === "string" && sessionId) return sessionId; + } + } + return undefined; +} diff --git a/frontend/app/app/claw-account-gate.tsx b/frontend/app/app/claw-account-gate.tsx new file mode 100644 index 0000000..650469f --- /dev/null +++ b/frontend/app/app/claw-account-gate.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { LogOutIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +export type ClawAccount = { + id: string; + username: string; +}; + +export function useClawAccount() { + const [account, setAccount] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const refresh = useCallback(async () => { + setIsLoading(true); + try { + const response = await fetch("/api/claw/auth/me", { cache: "no-store" }); + const payload = (await response.json()) as { + account?: ClawAccount | null; + }; + setAccount(payload.account ?? null); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + return { account, isLoading, refresh, setAccount }; +} + +export function ClawAccountGate({ + onAccount, +}: { + onAccount: (account: ClawAccount) => void; +}) { + const [mode, setMode] = useState<"login" | "register">("login"); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function submit() { + setError(""); + setIsSubmitting(true); + try { + const response = await fetch(`/api/claw/auth/${mode}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + const payload = (await response.json()) as { + account?: ClawAccount; + error?: string; + }; + if (!response.ok || !payload.account) { + throw new Error(payload.error ?? "账号操作失败"); + } + onAccount(payload.account); + } catch (err) { + setError(err instanceof Error ? err.message : "账号操作失败"); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+
+

Claw Data Agent

+

+ 登录后会按账号隔离会话、上传文件和生成产物。 +

+
+
+ setUsername(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") submit(); + }} + /> + setPassword(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") submit(); + }} + /> + {error ?

{error}

: null} + + +
+
+
+ ); +} + +export function ClawAccountBar({ + account, + onLogout, + children, +}: { + account: ClawAccount; + onLogout: () => void; + children: ReactNode; +}) { + async function logout() { + await fetch("/api/claw/auth/logout", { method: "POST" }); + window.localStorage.removeItem("claw.activeSessionId"); + onLogout(); + } + + return ( +
+ {children} + + {account.username} + + +
+ ); +} diff --git a/frontend/app/app/claw-llm-settings.tsx b/frontend/app/app/claw-llm-settings.tsx new file mode 100644 index 0000000..ecff7de --- /dev/null +++ b/frontend/app/app/claw-llm-settings.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { Settings2Icon } from "lucide-react"; +import type { ReactNode } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; + +type ClawState = { + base_url: string; + api_key?: string; + cwd?: string; + allow_shell?: boolean; + allow_write?: boolean; +}; + +const DEFAULT_STATE: ClawState = { + base_url: "http://model.mify.ai.srv/v1", + api_key: "sk-Jxoh5lf1jWg6HQZYYyfyagXudGxUXNAgkZklxMnnXHn7pFmU", +}; + +export function ClawLlmSettings() { + const [open, setOpen] = useState(false); + const [state, setState] = useState(DEFAULT_STATE); + const [status, setStatus] = useState(""); + const [isSaving, setIsSaving] = useState(false); + + const loadState = useCallback(async () => { + setStatus(""); + const response = await fetch("/api/claw/state"); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error ?? "读取后端配置失败"); + } + setState((current) => ({ + ...DEFAULT_STATE, + ...payload, + api_key: payload.api_key ?? current.api_key ?? DEFAULT_STATE.api_key, + })); + }, []); + + useEffect(() => { + if (!open) return; + loadState().catch((err: unknown) => { + setStatus(err instanceof Error ? err.message : "读取后端配置失败"); + }); + }, [loadState, open]); + + async function saveState() { + setIsSaving(true); + setStatus(""); + try { + const response = await fetch("/api/claw/state", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + base_url: state.base_url, + api_key: state.api_key, + }), + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error ?? payload.detail ?? "保存失败"); + } + setState({ + ...DEFAULT_STATE, + ...payload, + api_key: state.api_key, + }); + setStatus("已更新后端 API 配置"); + } catch (err) { + setStatus(err instanceof Error ? err.message : "保存失败"); + } finally { + setIsSaving(false); + } + } + + return ( + + + + + + + 后端 LLM API + + 调试时更新 Claw 后端 Agent 使用的 Base URL 和 API Key。 + + +
+ + + setState((current) => ({ + ...current, + base_url: event.target.value, + })) + } + /> + + + + setState((current) => ({ + ...current, + api_key: event.target.value, + })) + } + /> + +
+ {status ? ( +

{status}

+ ) : null} + + + + +
+
+ ); +} + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} diff --git a/frontend/app/app/favicon.ico b/frontend/app/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/frontend/app/app/favicon.ico differ diff --git a/frontend/app/src/app/globals.css b/frontend/app/app/globals.css similarity index 58% rename from frontend/app/src/app/globals.css rename to frontend/app/app/globals.css index 3882f6f..0ba32a0 100644 --- a/frontend/app/src/app/globals.css +++ b/frontend/app/app/globals.css @@ -1,117 +1,125 @@ @import "tailwindcss"; - -@plugin "tailwindcss-animate"; +@import "tw-animate-css"; @custom-variant dark (&:is(.dark *)); +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --animate-shimmer: shimmer-sweep var(--shimmer-duration, 1000ms) linear + infinite both; + @keyframes shimmer-sweep { + from { + background-position: 150% 0; + } + to { + background-position: -100% 0; + } + } +} + :root { + --radius: 0.625rem; --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); + --foreground: oklch(0.141 0.005 285.823); --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); + --card-foreground: oklch(0.141 0.005 285.823); --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); --destructive: oklch(0.577 0.245 27.325); - --destructive-foreground: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.87 0 0); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.015 286.067); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); - --radius: 0.625rem; --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.21 0.006 285.885); --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.87 0 0); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.705 0.015 286.067); } .dark { - --background: oklch(0.145 0 0); + --background: oklch(0.141 0.005 285.823); --foreground: oklch(0.985 0 0); - --card: oklch(0.145 0 0); + --card: oklch(0.21 0.006 285.885); --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.145 0 0); + --popover: oklch(0.21 0.006 285.885); --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.985 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); + --primary: oklch(0.92 0.004 286.32); + --primary-foreground: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.396 0.141 25.723); - --destructive-foreground: oklch(0.637 0.237 25.331); - --border: oklch(0.269 0 0); - --input: oklch(0.269 0 0); - --ring: oklch(0.439 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.552 0.016 285.938); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); + --sidebar: oklch(0.21 0.006 285.885); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent: oklch(0.274 0.006 286.033); --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(0.269 0 0); - --sidebar-ring: oklch(0.439 0 0); -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.552 0.016 285.938); } @layer base { @@ -119,33 +127,15 @@ @apply border-border outline-ring/50; } + :root { + color-scheme: light; + } + + :root.dark { + color-scheme: dark; + } + body { @apply bg-background text-foreground; } - - :root { - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - } - - .dark { - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - } -} - -@layer utilities { - .shadow-inner-right { - box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02); - } - - .shadow-inner-left { - box-shadow: inset 9px 0 6px -1px rgb(0 0 0 / 0.02); - } -} +} \ No newline at end of file diff --git a/frontend/app/app/layout.tsx b/frontend/app/app/layout.tsx new file mode 100644 index 0000000..3e85377 --- /dev/null +++ b/frontend/app/app/layout.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Claw Data Agent", + description: "assistant-ui based data agent workbench", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/frontend/app/app/page.tsx b/frontend/app/app/page.tsx new file mode 100644 index 0000000..b070d2e --- /dev/null +++ b/frontend/app/app/page.tsx @@ -0,0 +1,5 @@ +import { Assistant } from "./assistant"; + +export default function Home() { + return ; +} diff --git a/frontend/app/components.json b/frontend/app/components.json index 6296a74..7967de6 100644 --- a/frontend/app/components.json +++ b/frontend/app/components.json @@ -1,21 +1,21 @@ { - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "tailwind.config.js", - "css": "src/index.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "iconLibrary": "lucide" + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" } diff --git a/frontend/app/components/assistant-ui/activity-panel.tsx b/frontend/app/components/assistant-ui/activity-panel.tsx new file mode 100644 index 0000000..0669d83 --- /dev/null +++ b/frontend/app/components/assistant-ui/activity-panel.tsx @@ -0,0 +1,415 @@ +"use client"; + +import type { + MessageState, + ThreadAssistantMessagePart, + ToolCallMessagePart, + ToolCallMessagePartStatus, +} from "@assistant-ui/react"; +import { useAuiState } from "@assistant-ui/react"; +import { + BrainIcon, + CheckCircle2Icon, + ChevronDownIcon, + ClockIcon, + FileTextIcon, + PanelRightCloseIcon, + WrenchIcon, + XCircleIcon, +} from "lucide-react"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; + +type ActivityContextValue = { + open: boolean; + selectedId: string | null; + openItem: (id: string) => void; + close: () => void; +}; + +const ActivityContext = createContext(null); + +export function ActivityProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const openItem = useCallback((id: string) => { + setSelectedId(id); + setOpen(true); + }, []); + const close = useCallback(() => setOpen(false), []); + const value = useMemo( + () => ({ + open, + selectedId, + openItem, + close, + }), + [open, selectedId, openItem, close], + ); + + return ( + + {children} + + ); +} + +export function useActivityPanel() { + const value = useContext(ActivityContext); + if (!value) { + throw new Error("useActivityPanel must be used within ActivityProvider"); + } + return value; +} + +type ActivityItem = { + id: string; + kind: "reasoning" | "tool"; + title: string; + summary: string; + status: ToolCallMessagePartStatus["type"]; + argsText?: string; + result?: unknown; + resultSummary?: string; + rawResult?: string; + fileLinks?: string[]; +}; + +export function ActivityPanel() { + const { open, selectedId, openItem, close } = useActivityPanel(); + const messages = useAuiState((s) => s.thread.messages); + const items = useMemo(() => collectActivityItems(messages), [messages]); + const prevCountRef = useRef(0); + + useEffect(() => { + if (items.length > prevCountRef.current) { + openItem(items.at(-1)?.id ?? items[0]?.id ?? ""); + } + prevCountRef.current = items.length; + }, [items, openItem]); + + if (!open) return null; + + return ( + + ); +} + +function ActivityPanelItem({ + item, + open, + onOpenChange, +}: { + item: ActivityItem; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const Icon = item.kind === "tool" ? WrenchIcon : BrainIcon; + + return ( + + + +
+
+ + {item.title} +
+

+ {item.summary} +

+
+ +
+ +
+ {item.argsText ? ( + + ) : null} + {item.resultSummary ? ( + + ) : null} + {item.fileLinks?.length ? ( + + ) : null} + {item.rawResult ? ( + + + + 原始结果 + + + + + + ) : null} + {item.kind === "reasoning" ? ( +

+ 这里展示的是思考状态摘要,不展示模型完整隐藏思考过程。 +

+ ) : null} +
+
+
+ ); +} + +function ActivityStatusIcon({ status }: { status: ActivityItem["status"] }) { + if (status === "running") { + return ; + } + if (status === "incomplete") { + return ; + } + return ( + + ); +} + +function ActivityPre({ + title, + value, + className, +}: { + title: string; + value: string; + className?: string; +}) { + return ( +
+ {title ? ( +

{title}

+ ) : null} +
+				{value}
+			
+
+ ); +} + +function ActivityResultSummary({ value }: { value: string }) { + return ( +
+
+ + 结果摘要 +
+

{value}

+
+ ); +} + +function collectActivityItems(messages: readonly MessageState[]) { + const items: ActivityItem[] = []; + + for (const message of messages) { + if (message.role !== "assistant") continue; + const parts = message.content as readonly ThreadAssistantMessagePart[]; + for (const [index, part] of parts.entries()) { + const id = `${message.id}:${index}`; + if (part.type === "reasoning") { + const status = getPartStatus(message, part); + items.push({ + id, + kind: "reasoning", + title: status === "running" ? "思考中" : "思考完成", + summary: + part.text.trim() || + (status === "running" + ? "模型正在整理下一步行动。" + : "思考并执行完成。"), + status, + }); + } + if (part.type === "tool-call") { + items.push({ + id, + kind: "tool", + title: part.toolName, + summary: summarizeTool(message, part), + status: getPartStatus(message, part), + argsText: part.argsText, + result: decodeJsonString(part.result), + resultSummary: summarizeToolResult(decodeJsonString(part.result)), + rawResult: + part.result === undefined + ? undefined + : formatValue(decodeJsonString(part.result)), + fileLinks: extractLocalPaths( + [ + part.argsText, + part.result === undefined + ? undefined + : formatValue(decodeJsonString(part.result)), + ].filter(Boolean) as string[], + ), + }); + } + } + } + + return items; +} + +function ActivityFileLinks({ paths }: { paths: string[] }) { + return ( +
+
+ + 相关文件 +
+
+ {paths.map((filePath) => ( + + {basename(filePath)} + + ))} +
+
+ ); +} + +function getPartStatus( + message: Extract, + part: ThreadAssistantMessagePart, +): ToolCallMessagePartStatus["type"] { + if (message.status.type === "incomplete") return "incomplete"; + if (part.type === "tool-call" && part.result === undefined) return "running"; + if (message.status.type === "running") return "running"; + return "complete"; +} + +function summarizeTool( + message: Extract, + part: ToolCallMessagePart, +) { + const status = getPartStatus(message, part); + if (status === "running") return "调用工具中"; + if (status === "incomplete") return "工具调用未完成"; + if (part.result === undefined) return "已提交工具参数"; + return "工具调用完成"; +} + +function decodeJsonString(value: unknown): unknown { + if (typeof value !== "string") return value; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function formatValue(value: unknown) { + const decoded = decodeJsonString(value); + return typeof decoded === "string" + ? decoded + : JSON.stringify(decoded, null, 2); +} + +function summarizeToolResult(value: unknown) { + const decoded = decodeJsonString(value); + if (decoded === undefined) return undefined; + if (typeof decoded === "string") return trimText(decoded, 600); + if (!decoded || typeof decoded !== "object") return String(decoded); + + const record = decoded as Record; + const parts: string[] = []; + if (typeof record.tool === "string") parts.push(`工具: ${record.tool}`); + if (typeof record.ok === "boolean") + parts.push(record.ok ? "状态: 成功" : "状态: 失败"); + if (typeof record.error === "string") parts.push(`错误: ${record.error}`); + if (typeof record.content === "string") + parts.push(trimText(record.content, 600)); + if (!parts.length) parts.push(trimText(JSON.stringify(record, null, 2), 600)); + return parts.join("\n"); +} + +function trimText(text: string, maxLength: number) { + const normalized = text.trim(); + if (normalized.length <= maxLength) return normalized; + return `${normalized.slice(0, maxLength)}...`; +} + +function extractLocalPaths(values: string[]) { + const paths = new Set(); + for (const value of values) { + for (const match of value.matchAll(/\/Users\/[^\s"',)]+/g)) { + paths.add(match[0]); + } + } + return [...paths]; +} + +function basename(filePath: string) { + return filePath.split("/").filter(Boolean).at(-1) ?? filePath; +} diff --git a/frontend/app/components/assistant-ui/attachment.tsx b/frontend/app/components/assistant-ui/attachment.tsx new file mode 100644 index 0000000..52b4544 --- /dev/null +++ b/frontend/app/components/assistant-ui/attachment.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { + AttachmentPrimitive, + ComposerPrimitive, + MessagePrimitive, + useAui, + useAuiState, +} from "@assistant-ui/react"; +import { FileText, PlusIcon, XIcon } from "lucide-react"; +import { type FC, type PropsWithChildren, useEffect, useState } from "react"; +import { useShallow } from "zustand/shallow"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +const useFileSrc = (file: File | undefined) => { + const [src, setSrc] = useState(undefined); + + useEffect(() => { + if (!file) { + setSrc(undefined); + return; + } + + const objectUrl = URL.createObjectURL(file); + setSrc(objectUrl); + + return () => { + URL.revokeObjectURL(objectUrl); + }; + }, [file]); + + return src; +}; + +const useAttachmentSrc = () => { + const { file, src } = useAuiState( + useShallow((s): { file?: File; src?: string } => { + if (s.attachment.type !== "image") return {}; + if (s.attachment.file) return { file: s.attachment.file }; + const src = s.attachment.content?.filter((c) => c.type === "image")[0] + ?.image; + if (!src) return {}; + return { src }; + }), + ); + + return useFileSrc(file) ?? src; +}; + +type AttachmentPreviewProps = { + src: string; +}; + +const AttachmentPreview: FC = ({ src }) => { + const [isLoaded, setIsLoaded] = useState(false); + return ( + Attachment preview setIsLoaded(true)} + /> + ); +}; + +const AttachmentPreviewDialog: FC = ({ children }) => { + const src = useAttachmentSrc(); + + if (!src) return children; + + return ( + + + {children} + + + + Image Attachment Preview + +
+ +
+
+
+ ); +}; + +const AttachmentThumb: FC = () => { + const src = useAttachmentSrc(); + + return ( + + + + + + + ); +}; + +const AttachmentUI: FC = () => { + const aui = useAui(); + const isComposer = aui.attachment.source !== "message"; + + const isImage = useAuiState((s) => s.attachment.type === "image"); + const typeLabel = useAuiState((s) => { + const type = s.attachment.type; + switch (type) { + case "image": + return "Image"; + case "document": + return "Document"; + case "file": + return "File"; + default: + return type; + } + }); + + return ( + + + + +
+ +
+
+
+ {isComposer && } +
+ + + +
+ ); +}; + +const AttachmentRemove: FC = () => { + return ( + + + + + + ); +}; + +export const UserMessageAttachments: FC = () => { + return ( +
+ + {() => } + +
+ ); +}; + +export const ComposerAttachments: FC = () => { + return ( +
+ + {() => } + +
+ ); +}; + +export const ComposerAddAttachment: FC = () => { + return ( + + + + + + ); +}; diff --git a/frontend/app/components/assistant-ui/markdown-text.tsx b/frontend/app/components/assistant-ui/markdown-text.tsx new file mode 100644 index 0000000..73dd465 --- /dev/null +++ b/frontend/app/components/assistant-ui/markdown-text.tsx @@ -0,0 +1,243 @@ +"use client"; + +import "@assistant-ui/react-markdown/styles/dot.css"; + +import { + type CodeHeaderProps, + MarkdownTextPrimitive, + unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, + useIsMarkdownCodeBlock, +} from "@assistant-ui/react-markdown"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { type FC, memo, useState } from "react"; +import remarkGfm from "remark-gfm"; + +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { cn } from "@/lib/utils"; + +const MarkdownTextImpl = () => { + return ( + + ); +}; + +export const MarkdownText = memo(MarkdownTextImpl); + +const CodeHeader: FC = ({ language, code }) => { + const { isCopied, copyToClipboard } = useCopyToClipboard(); + const onCopy = () => { + if (!code || isCopied) return; + copyToClipboard(code); + }; + + return ( +
+ + {language} + + + {!isCopied && } + {isCopied && } + +
+ ); +}; + +const useCopyToClipboard = ({ + copiedDuration = 3000, +}: { + copiedDuration?: number; +} = {}) => { + const [isCopied, setIsCopied] = useState(false); + + const copyToClipboard = (value: string) => { + if (!value) return; + + navigator.clipboard.writeText(value).then(() => { + setIsCopied(true); + setTimeout(() => setIsCopied(false), copiedDuration); + }); + }; + + return { isCopied, copyToClipboard }; +}; + +const defaultComponents = memoizeMarkdownComponents({ + h1: ({ className, ...props }) => ( +

+ ), + h2: ({ className, ...props }) => ( +

+ ), + h3: ({ className, ...props }) => ( +

+ ), + h4: ({ className, ...props }) => ( +

+ ), + h5: ({ className, ...props }) => ( +

+ ), + h6: ({ className, ...props }) => ( +
+ ), + p: ({ className, ...props }) => ( +

+ ), + a: ({ className, ...props }) => ( + + ), + blockquote: ({ className, ...props }) => ( +

+ ), + ul: ({ className, ...props }) => ( +
    li]:mt-1", + className, + )} + {...props} + /> + ), + ol: ({ className, ...props }) => ( +
      li]:mt-1", + className, + )} + {...props} + /> + ), + hr: ({ className, ...props }) => ( +
      + ), + table: ({ className, ...props }) => ( + + ), + th: ({ className, ...props }) => ( + td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg", + className, + )} + {...props} + /> + ), + li: ({ className, ...props }) => ( +
    1. + ), + sup: ({ className, ...props }) => ( + a]:text-xs [&>a]:no-underline", className)} + {...props} + /> + ), + pre: ({ className, ...props }) => ( +
      +	),
      +	code: function Code({ className, ...props }) {
      +		const isCodeBlock = useIsMarkdownCodeBlock();
      +		return (
      +			
      +		);
      +	},
      +	CodeHeader,
      +});
      diff --git a/frontend/app/components/assistant-ui/reasoning.tsx b/frontend/app/components/assistant-ui/reasoning.tsx
      new file mode 100644
      index 0000000..98dc9fe
      --- /dev/null
      +++ b/frontend/app/components/assistant-ui/reasoning.tsx
      @@ -0,0 +1,275 @@
      +"use client";
      +
      +import {
      +	type ReasoningGroupComponent,
      +	type ReasoningMessagePartComponent,
      +	useAuiState,
      +	useScrollLock,
      +} from "@assistant-ui/react";
      +import { cva, type VariantProps } from "class-variance-authority";
      +import { BrainIcon, ChevronDownIcon } from "lucide-react";
      +import { memo, useCallback, useRef, useState } from "react";
      +import { MarkdownText } from "@/components/assistant-ui/markdown-text";
      +import {
      +	Collapsible,
      +	CollapsibleContent,
      +	CollapsibleTrigger,
      +} from "@/components/ui/collapsible";
      +import { cn } from "@/lib/utils";
      +
      +const ANIMATION_DURATION = 200;
      +
      +const reasoningVariants = cva("aui-reasoning-root mb-4 w-full", {
      +	variants: {
      +		variant: {
      +			outline: "rounded-lg border px-3 py-2",
      +			ghost: "",
      +			muted: "rounded-lg bg-muted/50 px-3 py-2",
      +		},
      +	},
      +	defaultVariants: {
      +		variant: "outline",
      +	},
      +});
      +
      +export type ReasoningRootProps = Omit<
      +	React.ComponentProps,
      +	"open" | "onOpenChange"
      +> &
      +	VariantProps & {
      +		open?: boolean;
      +		onOpenChange?: (open: boolean) => void;
      +		defaultOpen?: boolean;
      +	};
      +
      +function ReasoningRoot({
      +	className,
      +	variant,
      +	open: controlledOpen,
      +	onOpenChange: controlledOnOpenChange,
      +	defaultOpen = false,
      +	children,
      +	...props
      +}: ReasoningRootProps) {
      +	const collapsibleRef = useRef(null);
      +	const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
      +	const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
      +
      +	const isControlled = controlledOpen !== undefined;
      +	const isOpen = isControlled ? controlledOpen : uncontrolledOpen;
      +
      +	const handleOpenChange = useCallback(
      +		(open: boolean) => {
      +			if (!open) {
      +				lockScroll();
      +			}
      +			if (!isControlled) {
      +				setUncontrolledOpen(open);
      +			}
      +			controlledOnOpenChange?.(open);
      +		},
      +		[lockScroll, isControlled, controlledOnOpenChange],
      +	);
      +
      +	return (
      +		
      +			{children}
      +		
      +	);
      +}
      +
      +function ReasoningFade({ className, ...props }: React.ComponentProps<"div">) {
      +	return (
      +		
      + ); +} + +function ReasoningTrigger({ + active, + duration, + className, + ...props +}: React.ComponentProps & { + active?: boolean; + duration?: number; +}) { + const durationText = duration ? ` (${duration}s)` : ""; + + return ( + + + + Reasoning{durationText} + {active ? ( + + Reasoning{durationText} + + ) : null} + + + + ); +} + +function ReasoningContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + {children} + + + ); +} + +function ReasoningText({ className, ...props }: React.ComponentProps<"div">) { + return ( +
      + ); +} + +const ReasoningImpl: ReasoningMessagePartComponent = () => ; + +const ReasoningGroupImpl: ReasoningGroupComponent = ({ + children, + startIndex, + endIndex, +}) => { + const isReasoningStreaming = useAuiState((s) => { + if (s.message.status?.type !== "running") return false; + const lastIndex = s.message.parts.length - 1; + if (lastIndex < 0) return false; + const lastType = s.message.parts[lastIndex]?.type; + if (lastType !== "reasoning") return false; + return lastIndex >= startIndex && lastIndex <= endIndex; + }); + + return ( + + + + {children} + + + ); +}; + +const Reasoning = memo( + ReasoningImpl, +) as unknown as ReasoningMessagePartComponent & { + Root: typeof ReasoningRoot; + Trigger: typeof ReasoningTrigger; + Content: typeof ReasoningContent; + Text: typeof ReasoningText; + Fade: typeof ReasoningFade; +}; + +Reasoning.displayName = "Reasoning"; +Reasoning.Root = ReasoningRoot; +Reasoning.Trigger = ReasoningTrigger; +Reasoning.Content = ReasoningContent; +Reasoning.Text = ReasoningText; +Reasoning.Fade = ReasoningFade; + +const ReasoningGroup = memo(ReasoningGroupImpl); +ReasoningGroup.displayName = "ReasoningGroup"; + +export { + Reasoning, + ReasoningContent, + ReasoningFade, + ReasoningGroup, + ReasoningRoot, + ReasoningText, + ReasoningTrigger, + reasoningVariants, +}; diff --git a/frontend/app/components/assistant-ui/thread-list.tsx b/frontend/app/components/assistant-ui/thread-list.tsx new file mode 100644 index 0000000..f23f2f9 --- /dev/null +++ b/frontend/app/components/assistant-ui/thread-list.tsx @@ -0,0 +1,420 @@ +import { + bindExternalStoreMessage, + type ExportedMessageRepository, + type ThreadMessage, +} from "@assistant-ui/core"; +import { + AuiIf, + ThreadListItemMorePrimitive, + ThreadListItemPrimitive, + ThreadListPrimitive, +} from "@assistant-ui/react"; +import type { UIMessage } from "ai"; +import { + ArchiveIcon, + HistoryIcon, + MoreHorizontalIcon, + PlusIcon, +} from "lucide-react"; +import { type FC, useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useClawSessionReplay } from "@/lib/claw-session-replay"; + +type ClawSession = { + session_id: string; + turns: number; + tool_calls: number; + preview: string; + modified_at: number; +}; + +type ClawStoredMessage = { + role?: string; + content?: string; + name?: string; + tool_call_id?: string; + tool_calls?: ClawStoredToolCall[]; + metadata?: { + elapsed_ms?: unknown; + }; +}; + +type ClawStoredSession = { + session_id: string; + messages?: ClawStoredMessage[]; +}; + +type ClawStoredToolCall = { + id?: string; + name?: string; + arguments?: unknown; + function?: { + name?: string; + arguments?: unknown; + }; +}; + +export const ThreadList: FC = () => { + return ( + + + threads.isLoading}> + + + !threads.isLoading}> + + {() => } + + + + + ); +}; + +const ThreadListNew: FC = () => { + return ( +
      { + window.localStorage.removeItem("claw.activeSessionId"); + window.dispatchEvent(new Event("claw-active-session-cleared")); + }} + > + + + +
      + ); +}; + +const ThreadListSkeleton: FC = () => { + const skeletonKeys = ["one", "two", "three", "four", "five"]; + + return ( +
      + {skeletonKeys.map((key) => ( +
      + +
      + ))} +
      + ); +}; + +const ThreadListItem: FC = () => { + return ( + + + + + + + ); +}; + +const ThreadListItemMore: FC = () => { + return ( + + + + + + + + + Archive + + + + + ); +}; + +const ClawSessionList: FC = () => { + const { replaySession } = useClawSessionReplay(); + const [sessions, setSessions] = useState([]); + const [activeSessionId, setActiveSessionId] = useState(null); + const [loadingSessionId, setLoadingSessionId] = useState(null); + + useEffect(() => { + setActiveSessionId(window.localStorage.getItem("claw.activeSessionId")); + const clearActiveSession = () => setActiveSessionId(null); + window.addEventListener("claw-active-session-cleared", clearActiveSession); + fetch("/api/claw/sessions") + .then((res) => (res.ok ? res.json() : [])) + .then((payload) => { + if (Array.isArray(payload)) + setSessions(dedupeSessions(payload).slice(0, 8)); + }) + .catch(() => setSessions([])); + return () => { + window.removeEventListener( + "claw-active-session-cleared", + clearActiveSession, + ); + }; + }, []); + + if (!sessions.length) return null; + + return ( +
      +
      +
      + + Claw 历史会话 +
      + {activeSessionId ? ( + + ) : null} +
      +
      + {sessions.map((session) => { + const active = activeSessionId === session.session_id; + return ( + + ); + })} +
      +
      + ); +}; + +function dedupeSessions(payload: unknown[]) { + const byId = new Map(); + for (const item of payload) { + if (!isClawSession(item)) continue; + const previous = byId.get(item.session_id); + if (!previous || item.modified_at >= previous.modified_at) { + byId.set(item.session_id, item); + } + } + return [...byId.values()].sort((a, b) => b.modified_at - a.modified_at); +} + +function isClawSession(value: unknown): value is ClawSession { + if (!value || typeof value !== "object") return false; + const item = value as Partial; + return ( + typeof item.session_id === "string" && typeof item.modified_at === "number" + ); +} + +function toReplayRepository( + session: ClawStoredSession, + fallbackSessionId: string, +): ExportedMessageRepository { + const messages: ExportedMessageRepository["messages"] = []; + const toolResults = collectToolResults(session.messages ?? []); + let previousId: string | null = null; + for (const [index, message] of (session.messages ?? []).entries()) { + if (message.role === "tool") continue; + if (message.role !== "user" && message.role !== "assistant") continue; + const content = cleanStoredContent(message.content ?? ""); + if (!content) continue; + if (content.trimStart().startsWith("")) continue; + const id = `${fallbackSessionId}-${index}`; + const toolParts = + message.role === "assistant" + ? toToolParts(message.tool_calls ?? [], toolResults) + : []; + const elapsedMs = + typeof message.metadata?.elapsed_ms === "number" + ? message.metadata.elapsed_ms + : undefined; + const reasoningParts = + message.role === "assistant" && + (toolParts.length > 0 || elapsedMs !== undefined) + ? [ + { + type: "reasoning", + text: formatHistoricalReasoning(toolParts.length, elapsedMs), + }, + ] + : []; + const parts = ( + message.role === "assistant" && + (reasoningParts.length || toolParts.length) + ? [...reasoningParts, ...toolParts, { type: "text", text: content }] + : [{ type: "text", text: content }] + ) as UIMessage["parts"]; + const uiMessage: UIMessage = { + id, + role: message.role, + parts, + ...(message.role === "assistant" + ? { metadata: { sessionId: session.session_id ?? fallbackSessionId } } + : {}), + } as UIMessage; + const threadMessage = { + id, + role: message.role, + createdAt: new Date(), + content: toThreadMessageContent(parts), + ...(message.role === "assistant" + ? { + status: { type: "complete", reason: "stop" }, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: { sessionId: session.session_id ?? fallbackSessionId }, + }, + } + : { metadata: { custom: {} } }), + } as ThreadMessage; + bindExternalStoreMessage(threadMessage, uiMessage); + messages.push({ message: threadMessage, parentId: previousId }); + previousId = id; + } + return { + headId: previousId, + messages, + }; +} + +function cleanStoredContent(content: string) { + const markers = ["\n\n[当前会话目录]", "\n[当前会话目录]"]; + for (const marker of markers) { + if (content.includes(marker)) return content.split(marker, 1)[0].trim(); + } + return content.trim(); +} + +function collectToolResults(messages: readonly ClawStoredMessage[]) { + const results = new Map(); + for (const message of messages) { + if (message.role === "tool" && message.tool_call_id) { + results.set(message.tool_call_id, message.content ?? ""); + } + } + return results; +} + +function toToolParts( + toolCalls: readonly ClawStoredToolCall[], + toolResults: Map, +) { + return toolCalls.flatMap((call) => { + const toolCallId = call.id; + const toolName = call.function?.name ?? call.name; + if (!toolCallId || !toolName) return []; + const input = parseToolInput(call.function?.arguments ?? call.arguments); + const output = toolResults.get(toolCallId); + return [ + { + type: "dynamic-tool", + toolName, + toolCallId, + state: output === undefined ? "input-available" : "output-available", + input, + ...(output === undefined ? {} : { output }), + }, + ]; + }); +} + +function formatHistoricalReasoning(toolCount: number, elapsedMs?: number) { + const duration = + elapsedMs === undefined ? "" : `,用时 ${formatDuration(elapsedMs)}`; + if (toolCount <= 0) return `历史记录:思考完成${duration}。`; + return `历史记录:本轮调用了 ${toolCount} 个工具${duration}。`; +} + +function formatDuration(ms: number) { + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +function toThreadMessageContent(parts: UIMessage["parts"]) { + return parts.map((part) => { + if (part.type === "text" || part.type === "reasoning") return part; + if (part.type === "dynamic-tool") { + return { + type: "tool-call", + toolName: part.toolName, + toolCallId: part.toolCallId, + args: part.input ?? {}, + argsText: JSON.stringify(part.input ?? {}), + ...(part.state === "output-available" ? { result: part.output } : {}), + }; + } + return part; + }); +} + +function parseToolInput(value: unknown) { + if (typeof value !== "string") return value ?? {}; + try { + return JSON.parse(value); + } catch { + return value; + } +} diff --git a/frontend/app/components/assistant-ui/thread.tsx b/frontend/app/components/assistant-ui/thread.tsx new file mode 100644 index 0000000..825142e --- /dev/null +++ b/frontend/app/components/assistant-ui/thread.tsx @@ -0,0 +1,1204 @@ +import { + ActionBarMorePrimitive, + ActionBarPrimitive, + AuiIf, + BranchPickerPrimitive, + ComposerPrimitive, + ErrorPrimitive, + MessagePrimitive, + SuggestionPrimitive, + ThreadPrimitive, + useAui, + useAuiState, +} from "@assistant-ui/react"; +import { + ArrowDownIcon, + ArrowUpIcon, + BookOpenIcon, + BrainIcon, + CheckIcon, + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, + CopyIcon, + DownloadIcon, + ListIcon, + MoreHorizontalIcon, + PencilIcon, + RefreshCwIcon, + SlashIcon, + SquareIcon, + WrenchIcon, +} from "lucide-react"; +import { Popover as PopoverPrimitive } from "radix-ui"; +import type { ComponentProps, FC, KeyboardEvent, ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import TextareaAutosize from "react-textarea-autosize"; +import { useActivityPanel } from "@/components/assistant-ui/activity-panel"; +import { + ComposerAddAttachment, + ComposerAttachments, + UserMessageAttachments, +} from "@/components/assistant-ui/attachment"; +import { MarkdownText } from "@/components/assistant-ui/markdown-text"; +import { Reasoning } from "@/components/assistant-ui/reasoning"; +import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +type ComposerInsertEvent = CustomEvent<{ text: string }>; + +type SkillSummary = { + name: string; + description?: string; + when_to_use?: string; +}; + +type UsageSummary = { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + reasoning_tokens?: number; +}; + +type ClawState = { + model?: string; + active_session_id?: string | null; +}; + +type ClawStoredSession = { + session_id?: string; + model?: string; + usage?: UsageSummary; +}; + +type ModelOption = { + id: string; +}; + +export const Thread: FC = () => { + return ( + + +
      + s.thread.isEmpty}> + + + +
      + + {() => } + +
      + + + + + +
      +
      +
      + ); +}; + +const ThreadMessage: FC = () => { + const role = useAuiState((s) => s.message.role); + const isEditing = useAuiState((s) => s.message.composer.isEditing); + + if (isEditing) return ; + if (role === "user") return ; + return ; +}; + +const ThreadScrollToBottom: FC = () => { + return ( + + + + + + ); +}; + +const ThreadWelcome: FC = () => { + return ( +
      +
      +
      +

      + Hello there! +

      +

      + How can I help you today? +

      +
      +
      + +
      + ); +}; + +const ThreadSuggestions: FC = () => { + return ( +
      + + {() => } + +
      + ); +}; + +const ThreadSuggestionItem: FC = () => { + return ( +
      + + + +
      + ); +}; + +const Composer: FC = () => { + return ( + + +
      + + + +
      +
      +
      + ); +}; + +const ComposerAction: FC = () => { + return ( +
      +
      + + +
      + + !s.thread.isRunning}> + + + + + + + s.thread.isRunning}> + + + + +
      + ); +}; + +const ComposerContextStatus: FC = () => { + const status = useComposerContextStatus(); + const totalTokens = status.usage?.total_tokens ?? 0; + const limit = inferContextLimit(status.model); + const percent = Math.min(100, Math.round((totalTokens / limit) * 100)); + + return ( +
      + + +
      + ); +}; + +function ContextWindowButton({ + percent, + totalTokens, + limit, +}: { + percent: number; + totalTokens: number; + limit: number; +}) { + const ringStyle = { + background: `conic-gradient(var(--primary) ${percent * 3.6}deg, var(--muted) 0deg)`, + }; + + return ( + + + + + + + + +
      背景信息窗口:
      +
      + {percent}% 已用(剩余 {Math.max(0, 100 - percent)}%) +
      +
      + 已用 {formatCompactTokenCount(totalTokens)} 标记,共{" "} + {formatCompactTokenCount(limit)} +
      +
      +
      + + +
      背景信息窗口:
      +
      + {percent}% 已用(剩余 {Math.max(0, 100 - percent)}%) +
      +
      + 已用 {formatCompactTokenCount(totalTokens)} 标记,共{" "} + {formatCompactTokenCount(limit)} +
      +
      + Codex 自动压缩其背景信息 +
      +
      +
      +
      + ); +} + +function ModelPickerButton({ + status, +}: { + status: { + model?: string; + models: ModelOption[]; + setModel: (model: string) => Promise; + }; +}) { + return ( + + + + + + +
      + 切换模型 +
      + {status.models.length ? ( + status.models.map((model) => ( + + )) + ) : ( +
      + 模型列表读取中 +
      + )} +
      +
      +
      + ); +} + +function useComposerContextStatus() { + const isRunning = useAuiState((s) => s.thread.isRunning); + const latestSessionId = useAuiState((s) => + findLatestMessageMetadataValue(s.thread.messages, "sessionId"), + ); + const latestUsage = useAuiState((s) => + findLatestMessageMetadataValue(s.thread.messages, "usage"), + ) as UsageSummary | undefined; + const [status, setStatus] = useState<{ + model?: string; + usage?: UsageSummary; + sessionId?: string | null; + models: ModelOption[]; + setModel: (model: string) => Promise; + }>({ + models: [], + setModel: async () => {}, + }); + + useEffect(() => { + if (typeof latestSessionId === "string" && latestSessionId) { + window.localStorage.setItem("claw.activeSessionId", latestSessionId); + } + }, [latestSessionId]); + + useEffect(() => { + let cancelled = false; + + async function refresh() { + try { + const stateResponse = await fetch("/api/claw/state", { + cache: "no-store", + }); + const statePayload = stateResponse.ok + ? ((await stateResponse.json()) as ClawState) + : {}; + const sessionId = + (typeof latestSessionId === "string" && latestSessionId) || + window.localStorage.getItem("claw.activeSessionId") || + statePayload.active_session_id || + null; + let sessionPayload: ClawStoredSession | null = null; + if (sessionId) { + const sessionResponse = await fetch( + `/api/claw/sessions/${sessionId}`, + { + cache: "no-store", + }, + ); + sessionPayload = sessionResponse.ok + ? ((await sessionResponse.json()) as ClawStoredSession) + : null; + } + if (cancelled) return; + setStatus((current) => ({ + model: sessionPayload?.model ?? statePayload.model, + usage: latestUsage ?? sessionPayload?.usage, + sessionId, + models: current.models, + setModel: current.setModel, + })); + } catch { + if (!cancelled) { + setStatus((current) => ({ + ...current, + usage: latestUsage ?? current.usage, + })); + } + } + } + + refresh(); + const onRefresh = () => refresh(); + window.addEventListener("focus", onRefresh); + window.addEventListener("claw-active-session-cleared", onRefresh); + const interval = window.setInterval(refresh, isRunning ? 2000 : 8000); + return () => { + cancelled = true; + window.removeEventListener("focus", onRefresh); + window.removeEventListener("claw-active-session-cleared", onRefresh); + window.clearInterval(interval); + }; + }, [isRunning, latestSessionId, latestUsage]); + + useEffect(() => { + let cancelled = false; + + async function refreshModels() { + try { + const response = await fetch("/api/claw/models", { + cache: "no-store", + }); + const payload = (await response.json()) as { models?: ModelOption[] }; + if (!cancelled && response.ok) { + setStatus((current) => ({ + ...current, + models: Array.isArray(payload.models) ? payload.models : [], + })); + } + } catch { + if (!cancelled) { + setStatus((current) => ({ ...current, models: [] })); + } + } + } + + refreshModels(); + return () => { + cancelled = true; + }; + }, []); + + const setModel = useCallback(async (model: string) => { + const response = await fetch("/api/claw/state", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model }), + }); + const payload = (await response.json()) as ClawState; + if (!response.ok) return; + setStatus((current) => ({ + ...current, + model: payload.model ?? model, + })); + }, []); + + useEffect(() => { + setStatus((current) => ({ ...current, setModel })); + }, [setModel]); + + return status; +} + +function findLatestMessageMetadataValue( + messages: readonly unknown[], + key: string, +) { + for (const message of [...messages].reverse()) { + if (!message || typeof message !== "object") continue; + const metadata = (message as { metadata?: unknown }).metadata; + const value = readMetadataValue(metadata, key); + if (value !== undefined) return value; + } + return undefined; +} + +function readMetadataValue(metadata: unknown, key: string): unknown { + if (!metadata || typeof metadata !== "object") return undefined; + const record = metadata as Record; + if (record[key] !== undefined) return record[key]; + const custom = record.custom; + if (custom && typeof custom === "object") { + return (custom as Record)[key]; + } + return undefined; +} + +function inferContextLimit(model?: string) { + const lower = (model ?? "").toLowerCase(); + if (lower.includes("qwen3-32b")) return 128_000; + if (lower.includes("deepseek")) return 128_000; + if (lower.includes("mimo")) return 128_000; + return 128_000; +} + +function formatCompactTokenCount(value: number) { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; + return `${value}`; +} + +function compactModelLabel(model?: string) { + if (!model) return "模型"; + const value = model.includes("/") ? model.split("/").at(-1) || model : model; + if (value.toLowerCase().startsWith("mimo-v2.5")) return "MiMo 2.5"; + if (value.toLowerCase().startsWith("mimo-v2")) return "MiMo 2"; + if (value.toLowerCase().startsWith("deepseek")) return "DeepSeek"; + if (value.toLowerCase().startsWith("qwen")) return "Qwen"; + if (value.toLowerCase().startsWith("minimax")) return "MiniMax"; + return value; +} + +function modelProvider(model: string) { + if (model.includes("/")) return model.split("/", 1)[0] || "unknown"; + return "default"; +} + +const ComposerAssistButtons: FC = () => { + return ( +
      + } + triggerLabel="/" + items={QUICK_PROMPTS} + /> + + } + triggerLabel="Tools" + items={TOOL_PROMPTS} + /> +
      + ); +}; + +const QUICK_PROMPTS = [ + { + title: "产品定义生成数据", + description: "从产品定义、例子 query 或手写边界整理数据目标。", + prompt: + "请根据我提供的产品定义或手写规则,先总结 query 语义边界、目标标签和排除范围,给我 review 数据生成目标,确认后再生成 canonical records。", + }, + { + title: "线上数据挖掘", + description: "构造关键词/正则/domain 条件,采样候选并转换线上样本。", + prompt: + "请进入线上数据挖掘流程:先理解我的挖掘需求,设计筛选策略并采样候选,待我 review 后,把选中的线上数据直接转换为 canonical records,不要走生成数据分支。", + }, + { + title: "校验 records", + description: "检查 canonical records 的字段、标签和多轮上下文。", + prompt: + "请校验我提供的 canonical records,重点检查 query、target、prev_session、timestamp 和标签格式,并输出需要修复的问题。", + }, +]; + +const TOOL_PROMPTS = [ + { + title: "data_agent_profile_router_sessions", + description: "查看 parquet 线上 session 数据字段、规模和样例。", + prompt: + "请优先使用 data_agent_profile_router_sessions 了解我指定的 router_session_parquet 数据目录,再给出可用字段和初步挖掘建议。", + }, + { + title: "data_agent_search_router_sessions", + description: "用关键词、正则、domain、device 等条件筛选候选。", + prompt: + "请优先使用 data_agent_search_router_sessions 按我的条件筛选线上候选,并说明筛选策略、命中数量和样例质量。", + }, + { + title: "data_agent_normalize_dataset_draft", + description: "把自然语言 draft 转换为 canonical records。", + prompt: + "请在我确认数据内容后,使用 data_agent_normalize_dataset_draft 把 draft 转换成 canonical records。", + }, + { + title: "data_agent_validate_dataset_records", + description: "校验 canonical records 是否符合结构约束。", + prompt: + "请使用 data_agent_validate_dataset_records 校验当前 canonical records,并解释每类错误应该如何修复。", + }, +]; + +function PromptInsertDialog({ + title, + description, + triggerIcon, + triggerLabel, + items, +}: { + title: string; + description: string; + triggerIcon: ReactNode; + triggerLabel: string; + items: { title: string; description: string; prompt: string }[]; +}) { + const [open, setOpen] = useState(false); + + return ( + + + + + + + {title} + {description} + +
      + {items.map((item) => ( + + ))} +
      +
      +
      + ); +} + +function SkillInsertDialog() { + const [open, setOpen] = useState(false); + const [skills, setSkills] = useState([]); + const [status, setStatus] = useState("点击后读取当前后端 Skill 列表。"); + + async function loadSkills() { + setStatus("正在读取 Skill 列表..."); + try { + const response = await fetch("/api/claw/skills", { cache: "no-store" }); + const payload = (await response.json()) as + | SkillSummary[] + | { error?: string }; + if (!response.ok || !Array.isArray(payload)) { + throw new Error( + Array.isArray(payload) ? "读取失败" : (payload.error ?? "读取失败"), + ); + } + setSkills(payload); + setStatus(payload.length ? "" : "当前没有可用 Skill。"); + } catch (err) { + setStatus(err instanceof Error ? err.message : "读取 Skill 失败"); + } + } + + return ( + { + setOpen(open); + if (open) loadSkills(); + }} + > + + + + + + Skills + + 选择 Skill 后会插入可编辑提示词,便于调试和明确任务入口。 + + + {status ? ( +

      {status}

      + ) : null} +
      + {skills.map((skill) => ( + + ))} +
      +
      +
      + ); +} + +function insertComposerText(text: string) { + window.dispatchEvent( + new CustomEvent("claw-composer-insert", { + detail: { text }, + }), + ); +} + +const MessageError: FC = () => { + return ( + + + + + + ); +}; + +const AssistantMessage: FC = () => { + const ACTION_BAR_PT = "pt-1.5"; + const ACTION_BAR_HEIGHT = `-mb-7.5 min-h-7.5 ${ACTION_BAR_PT}`; + const messageId = useAuiState((s) => s.message.id); + + return ( + +
      + { + if (part.type === "reasoning" || part.type === "tool-call") + return ["group-chain"]; + return null; + }} + > + {({ part }) => { + switch (part.type) { + case "group-chain": + return ( + + ); + case "text": + return ; + case "reasoning": + return ; + case "tool-call": + return part.toolUI ?? ; + default: + return null; + } + }} + + +
      + +
      + + +
      +
      + ); +}; + +const ActivityChainSummary: FC<{ + messageId: string; + partIndex: number | undefined; +}> = ({ messageId, partIndex }) => { + const { openItem } = useActivityPanel(); + const label = useAuiState((s) => { + const message = s.message; + return summarizeActivityChainLabel(message.content, message.status?.type); + }); + const active = useAuiState((s) => { + const message = s.message; + return message.status?.type === "running"; + }); + + return ( + + ); +}; + +function summarizeActivityChainLabel( + content: readonly { type: string; text?: string; result?: unknown }[], + status: string | undefined, +) { + const toolCount = content.filter((part) => part.type === "tool-call").length; + const runningToolCount = content.filter( + (part) => part.type === "tool-call" && part.result === undefined, + ).length; + const reasoningText = + content.find((part) => part.type === "reasoning")?.text ?? ""; + const lastReasoningLine = reasoningText + .trim() + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .at(-1); + + if (status === "running") { + return runningToolCount > 0 ? "调用工具中" : "思考中"; + } + + const label = lastReasoningLine || "思考完成"; + return toolCount > 0 ? `${label} · 调用了 ${toolCount} 个工具` : label; +} + +const AssistantActionBar: FC = () => { + return ( + + + + s.message.isCopied}> + + + !s.message.isCopied}> + + + + + + + + + + + + + + + + + + + + Export as Markdown + + + + + + ); +}; + +const UserMessage: FC = () => { + return ( + + + +
      +
      + +
      +
      + +
      +
      + + +
      + ); +}; + +const UserActionBar: FC = () => { + return ( + + + + + + + + ); +}; + +const EditComposer: FC = () => { + return ( + + + +
      + + + + + + +
      +
      +
      + ); +}; + +const ImeComposerInput: FC> = ({ + autoFocus, + onChange, + onCompositionEnd, + onCompositionStart, + onKeyDown, + disabled, + ...props +}) => { + const aui = useAui(); + const storeText = useAuiState((s) => + s.composer.isEditing ? s.composer.text : "", + ); + const runtimeDisabled = useAuiState( + (s) => s.thread.isDisabled || s.composer.dictation?.inputDisabled, + ); + const [localText, setLocalText] = useState(storeText); + const textareaRef = useRef(null); + const isComposingRef = useRef(false); + const isDisabled = runtimeDisabled || disabled; + + useEffect(() => { + if (!isComposingRef.current) { + setLocalText(storeText); + } + }, [storeText]); + + useEffect(() => { + if ( + !isComposingRef.current && + localText !== storeText && + aui.composer().getState().isEditing + ) { + aui.composer().setText(localText); + } + }, [aui, localText, storeText]); + + useEffect(() => { + if (!autoFocus || isDisabled) return; + const textarea = textareaRef.current; + if (!textarea) return; + textarea.focus({ preventScroll: true }); + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + }, [autoFocus, isDisabled]); + + const syncComposerText = useCallback( + (value: string) => { + if (!aui.composer().getState().isEditing) return; + aui.composer().setText(value); + }, + [aui], + ); + + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + const handleInsert = (event: Event) => { + const insertText = (event as ComposerInsertEvent).detail?.text; + if (!insertText) return; + const nextText = [localText.trimEnd(), insertText.trim()] + .filter(Boolean) + .join("\n\n"); + setLocalText(nextText); + syncComposerText(nextText); + requestAnimationFrame(() => { + textarea.focus({ preventScroll: true }); + textarea.setSelectionRange(nextText.length, nextText.length); + }); + }; + window.addEventListener("claw-composer-insert", handleInsert); + return () => + window.removeEventListener("claw-composer-insert", handleInsert); + }, [localText, syncComposerText]); + + const handleKeyDown = (event: KeyboardEvent) => { + onKeyDown?.(event); + if (event.defaultPrevented || isDisabled) return; + if ( + event.nativeEvent.isComposing || + isComposingRef.current || + event.key !== "Enter" || + event.shiftKey + ) { + return; + } + + const threadState = aui.thread().getState(); + if (threadState.isRunning && !threadState.capabilities.queue) return; + + event.preventDefault(); + syncComposerText(localText); + aui.composer().send(); + setLocalText(""); + }; + + return ( + { + onChange?.(event); + const nextText = event.currentTarget.value; + setLocalText(nextText); + const isNativeComposing = + (event.nativeEvent as { isComposing?: boolean }).isComposing === true; + if (!isNativeComposing && !isComposingRef.current) { + syncComposerText(nextText); + } + }} + onCompositionStart={(event) => { + onCompositionStart?.(event); + isComposingRef.current = true; + }} + onCompositionEnd={(event) => { + onCompositionEnd?.(event); + isComposingRef.current = false; + const target = event.currentTarget; + queueMicrotask(() => { + const nextText = target.value; + setLocalText(nextText); + syncComposerText(nextText); + }); + }} + onKeyDown={handleKeyDown} + /> + ); +}; + +const BranchPicker: FC = ({ + className, + ...rest +}) => { + return ( + + + + + + + + / + + + + + + + + ); +}; diff --git a/frontend/app/components/assistant-ui/threadlist-sidebar.tsx b/frontend/app/components/assistant-ui/threadlist-sidebar.tsx new file mode 100644 index 0000000..135c1cb --- /dev/null +++ b/frontend/app/components/assistant-ui/threadlist-sidebar.tsx @@ -0,0 +1,341 @@ +import { + BarChart3Icon, + BotIcon, + ChevronDownIcon, + LogOutIcon, + UserIcon, +} from "lucide-react"; +import { Popover as PopoverPrimitive } from "radix-ui"; +import type * as React from "react"; +import { useEffect, useState } from "react"; +import type { ClawAccount } from "@/app/claw-account-gate"; +import { ThreadList } from "@/components/assistant-ui/thread-list"; +import { Button } from "@/components/ui/button"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarRail, +} from "@/components/ui/sidebar"; + +type ThreadListSidebarProps = React.ComponentProps & { + account: ClawAccount; + onLogout: () => void; +}; + +type ClawState = { + model?: string; + session_directory?: string; + active_session_id?: string | null; +}; + +type ClawSessionSummary = { + session_id: string; + turns: number; + tool_calls: number; + modified_at: number; + model?: string; + usage?: UsageSummary; +}; + +type UsageSummary = { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; +}; + +type ClawStoredSession = { + session_id: string; + usage?: UsageSummary; + tool_calls?: number; + turns?: number; + model?: string; +}; + +export function ThreadListSidebar({ + account, + onLogout, + ...props +}: ThreadListSidebarProps) { + return ( + + +
      + + + +
      + +
      +
      + + Claw Data Agent + + + 数据开发工作台 + +
      +
      +
      +
      +
      +
      + + + + + + + +
      + ); +} + +function AccountMenu({ + account, + onLogout, +}: { + account: ClawAccount; + onLogout: () => void; +}) { + const [open, setOpen] = useState(false); + const [state, setState] = useState(null); + const [sessions, setSessions] = useState([]); + const [activeSession, setActiveSession] = useState( + null, + ); + const [modelUsageOpen, setModelUsageOpen] = useState(false); + + useEffect(() => { + if (!open) return; + Promise.all([ + fetch("/api/claw/state", { cache: "no-store" }).then((res) => + res.ok ? res.json() : null, + ), + fetch("/api/claw/sessions", { cache: "no-store" }).then((res) => + res.ok ? res.json() : [], + ), + ]) + .then(async ([statePayload, sessionsPayload]) => { + const nextState = statePayload as ClawState | null; + const nextSessions = Array.isArray(sessionsPayload) + ? (sessionsPayload as ClawSessionSummary[]) + : []; + setState(nextState); + setSessions(nextSessions); + const activeSessionId = + window.localStorage.getItem("claw.activeSessionId") ?? + nextState?.active_session_id; + if (!activeSessionId) { + setActiveSession(null); + return; + } + const response = await fetch(`/api/claw/sessions/${activeSessionId}`, { + cache: "no-store", + }); + setActiveSession(response.ok ? await response.json() : null); + }) + .catch(() => { + setState(null); + setSessions([]); + setActiveSession(null); + }); + }, [open]); + + async function logout() { + await fetch("/api/claw/auth/logout", { method: "POST" }); + window.localStorage.removeItem("claw.activeSessionId"); + onLogout(); + } + + const totalToolCalls = sessions.reduce( + (sum, session) => sum + (session.tool_calls ?? 0), + 0, + ); + const modelUsage = aggregateUsageByModel(sessions); + const modelUsageTotalTokens = modelUsage.reduce( + (sum, item) => sum + item.totalTokens, + 0, + ); + + return ( + + + + + + +
      +
      + +
      +
      +
      {account.username}
      +
      + {state?.model ?? "模型配置读取中"} +
      +
      +
      +
      + + + +
      +
      +
      + + 当前会话 +
      +
      + 输入 token + + {activeSession?.usage?.input_tokens ?? "暂无"} + + 输出 token + + {activeSession?.usage?.output_tokens ?? "暂无"} + + 工具调用 + + {activeSession?.tool_calls ?? "暂无"} + +
      +
      +
      + + {modelUsageOpen && modelUsage.length ? ( +
      + {modelUsage.map((item) => ( +
      + + {item.model} + + {formatCompactNumber(item.totalTokens)} tokens + + {item.sessions} 会话 · {item.toolCalls} 工具 + + + in {formatCompactNumber(item.inputTokens)} / out{" "} + {formatCompactNumber(item.outputTokens)} + +
      + ))} +
      + ) : null} + {modelUsageOpen && !modelUsage.length ? ( +
      暂无可统计会话
      + ) : null} +
      + +
      +
      +
      + ); +} + +function aggregateUsageByModel(sessions: ClawSessionSummary[]) { + const byModel = new Map< + string, + { + model: string; + sessions: number; + toolCalls: number; + inputTokens: number; + outputTokens: number; + totalTokens: number; + } + >(); + for (const session of sessions) { + const model = session.model || "unknown"; + const current = byModel.get(model) ?? { + model, + sessions: 0, + toolCalls: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + }; + current.sessions += 1; + current.toolCalls += session.tool_calls ?? 0; + current.inputTokens += session.usage?.input_tokens ?? 0; + current.outputTokens += session.usage?.output_tokens ?? 0; + current.totalTokens += + session.usage?.total_tokens ?? + (session.usage?.input_tokens ?? 0) + (session.usage?.output_tokens ?? 0); + byModel.set(model, current); + } + return [...byModel.values()].sort((a, b) => b.totalTokens - a.totalTokens); +} + +function formatCompactNumber(value: number) { + return new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, + }).format(value); +} + +function AccountStat({ + label, + value, +}: { + label: string; + value: string | number; +}) { + return ( +
      +
      {value}
      +
      {label}
      +
      + ); +} diff --git a/frontend/app/components/assistant-ui/tool-fallback.tsx b/frontend/app/components/assistant-ui/tool-fallback.tsx new file mode 100644 index 0000000..9b40c96 --- /dev/null +++ b/frontend/app/components/assistant-ui/tool-fallback.tsx @@ -0,0 +1,324 @@ +"use client"; + +import { + type ToolCallMessagePartComponent, + type ToolCallMessagePartStatus, + useScrollLock, +} from "@assistant-ui/react"; +import { + AlertCircleIcon, + CheckIcon, + ChevronDownIcon, + LoaderIcon, + XCircleIcon, +} from "lucide-react"; +import { memo, useCallback, useRef, useState } from "react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; + +const ANIMATION_DURATION = 200; + +export type ToolFallbackRootProps = Omit< + React.ComponentProps, + "open" | "onOpenChange" +> & { + open?: boolean; + onOpenChange?: (open: boolean) => void; + defaultOpen?: boolean; +}; + +function ToolFallbackRoot({ + className, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, + defaultOpen = false, + children, + ...props +}: ToolFallbackRootProps) { + const collapsibleRef = useRef(null); + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); + + const isControlled = controlledOpen !== undefined; + const isOpen = isControlled ? controlledOpen : uncontrolledOpen; + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + lockScroll(); + } + if (!isControlled) { + setUncontrolledOpen(open); + } + controlledOnOpenChange?.(open); + }, + [lockScroll, isControlled, controlledOnOpenChange], + ); + + return ( + + {children} + + ); +} + +type ToolStatus = ToolCallMessagePartStatus["type"]; + +const statusIconMap: Record = { + running: LoaderIcon, + complete: CheckIcon, + incomplete: XCircleIcon, + "requires-action": AlertCircleIcon, +}; + +function ToolFallbackTrigger({ + toolName, + status, + className, + ...props +}: React.ComponentProps & { + toolName: string; + status?: ToolCallMessagePartStatus; +}) { + const statusType = status?.type ?? "complete"; + const isRunning = statusType === "running"; + const isCancelled = + status?.type === "incomplete" && status.reason === "cancelled"; + + const Icon = statusIconMap[statusType]; + const label = isCancelled ? "Cancelled tool" : "Used tool"; + + return ( + + + + + {label}: {toolName} + + {isRunning && ( + + {label}: {toolName} + + )} + + + + ); +} + +function ToolFallbackContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
      {children}
      +
      + ); +} + +function ToolFallbackArgs({ + argsText, + className, + ...props +}: React.ComponentProps<"div"> & { + argsText?: string; +}) { + if (!argsText) return null; + + return ( +
      +
      +				{argsText}
      +			
      +
      + ); +} + +function ToolFallbackResult({ + result, + className, + ...props +}: React.ComponentProps<"div"> & { + result?: unknown; +}) { + if (result === undefined) return null; + + return ( +
      +

      Result:

      +
      +				{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
      +			
      +
      + ); +} + +function ToolFallbackError({ + status, + className, + ...props +}: React.ComponentProps<"div"> & { + status?: ToolCallMessagePartStatus; +}) { + if (status?.type !== "incomplete") return null; + + const error = status.error; + const errorText = error + ? typeof error === "string" + ? error + : JSON.stringify(error) + : null; + + if (!errorText) return null; + + const isCancelled = status.reason === "cancelled"; + const headerText = isCancelled ? "Cancelled reason:" : "Error:"; + + return ( +
      +

      + {headerText} +

      +

      + {errorText} +

      +
      + ); +} + +const ToolFallbackImpl: ToolCallMessagePartComponent = ({ + toolName, + argsText, + result, + status, +}) => { + const isCancelled = + status?.type === "incomplete" && status.reason === "cancelled"; + + return ( + + + + + + {!isCancelled && } + + + ); +}; + +const ToolFallback = memo( + ToolFallbackImpl, +) as unknown as ToolCallMessagePartComponent & { + Root: typeof ToolFallbackRoot; + Trigger: typeof ToolFallbackTrigger; + Content: typeof ToolFallbackContent; + Args: typeof ToolFallbackArgs; + Result: typeof ToolFallbackResult; + Error: typeof ToolFallbackError; +}; + +ToolFallback.displayName = "ToolFallback"; +ToolFallback.Root = ToolFallbackRoot; +ToolFallback.Trigger = ToolFallbackTrigger; +ToolFallback.Content = ToolFallbackContent; +ToolFallback.Args = ToolFallbackArgs; +ToolFallback.Result = ToolFallbackResult; +ToolFallback.Error = ToolFallbackError; + +export { + ToolFallback, + ToolFallbackArgs, + ToolFallbackContent, + ToolFallbackError, + ToolFallbackResult, + ToolFallbackRoot, + ToolFallbackTrigger, +}; diff --git a/frontend/app/components/assistant-ui/tooltip-icon-button.tsx b/frontend/app/components/assistant-ui/tooltip-icon-button.tsx new file mode 100644 index 0000000..f621d83 --- /dev/null +++ b/frontend/app/components/assistant-ui/tooltip-icon-button.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { Slot } from "radix-ui"; +import { type ComponentPropsWithRef, forwardRef } from "react"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +export type TooltipIconButtonProps = ComponentPropsWithRef & { + tooltip: string; + side?: "top" | "bottom" | "left" | "right"; +}; + +export const TooltipIconButton = forwardRef< + HTMLButtonElement, + TooltipIconButtonProps +>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => { + return ( + + + + + {tooltip} + + ); +}); + +TooltipIconButton.displayName = "TooltipIconButton"; diff --git a/frontend/app/components/icons/github.tsx b/frontend/app/components/icons/github.tsx new file mode 100644 index 0000000..0b5f5d5 --- /dev/null +++ b/frontend/app/components/icons/github.tsx @@ -0,0 +1,7 @@ +export function GitHubIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/frontend/app/components/ui/avatar.tsx b/frontend/app/components/ui/avatar.tsx new file mode 100644 index 0000000..c7dd925 --- /dev/null +++ b/frontend/app/components/ui/avatar.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { Avatar as AvatarPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg"; +}) { + return ( + + ); +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className, + )} + {...props} + /> + ); +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
      + ); +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
      svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className, + )} + {...props} + /> + ); +} + +export { + Avatar, + AvatarBadge, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarImage, +}; diff --git a/frontend/app/components/ui/breadcrumb.tsx b/frontend/app/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..2f4138b --- /dev/null +++ b/frontend/app/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return
    2. + ), + td: ({ className, ...props }) => ( + + ), + tr: ({ className, ...props }) => ( +