Add assistant-ui data agent frontend

This commit is contained in:
武阳
2026-05-06 16:18:32 +08:00
parent d6a2359dc1
commit 7d4ae3e7ba
119 changed files with 14330 additions and 14420 deletions
+306 -38
View File
@@ -8,9 +8,12 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import re
import time
from pathlib import Path from pathlib import Path
from threading import Lock from threading import Lock
from typing import Any from typing import Any
from urllib import error, request
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
@@ -56,15 +59,42 @@ class AgentState:
self.cwd = cwd.resolve() self.cwd = cwd.resolve()
self.session_directory = session_directory self.session_directory = session_directory
self._lock = Lock() self._lock = Lock()
self._agent: LocalCodingAgent | None = None self._agents: dict[str, LocalCodingAgent] = {}
self.model = model self.model = model
self.base_url = base_url self.base_url = base_url
self.api_key = api_key self.api_key = api_key
self.allow_shell = allow_shell self.allow_shell = allow_shell
self.allow_write = allow_write 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( permissions = AgentPermissions(
allow_file_write=self.allow_write, allow_file_write=self.allow_write,
allow_shell_commands=self.allow_shell, allow_shell_commands=self.allow_shell,
@@ -72,22 +102,26 @@ class AgentState:
runtime_config = AgentRuntimeConfig( runtime_config = AgentRuntimeConfig(
cwd=self.cwd, cwd=self.cwd,
permissions=permissions, permissions=permissions,
session_directory=self.session_directory, session_directory=paths['sessions'],
scratchpad_root=paths['scratchpad'],
) )
model_config = ModelConfig( model_config = ModelConfig(
model=self.model, model=self.model,
base_url=self.base_url, base_url=self.base_url,
api_key=self.api_key, api_key=self.api_key,
) )
self._agent = LocalCodingAgent( return LocalCodingAgent(
model_config=model_config, model_config=model_config,
runtime_config=runtime_config, runtime_config=runtime_config,
) )
@property def agent_for(self, account_id: str | None = None) -> LocalCodingAgent:
def agent(self) -> LocalCodingAgent: key = _safe_account_id(account_id) if account_id else '__default__'
assert self._agent is not None agent = self._agents.get(key)
return self._agent if agent is None:
agent = self._build_agent(account_id)
self._agents[key] = agent
return agent
def update( def update(
self, self,
@@ -98,10 +132,13 @@ class AgentState:
cwd: str | None = None, cwd: str | None = None,
allow_shell: bool | None = None, allow_shell: bool | None = None,
allow_write: bool | None = None, allow_write: bool | None = None,
account_id: str | None = None,
) -> None: ) -> None:
with self._lock: with self._lock:
# account_id 只用于让调用方拿到对应账号的 snapshot,不参与全局配置更新。
del account_id
if model is not None: if model is not None:
self.model = model self.model = _normalize_chat_model_name(model)
if base_url is not None: if base_url is not None:
self.base_url = base_url self.base_url = base_url
if api_key is not None: if api_key is not None:
@@ -115,17 +152,23 @@ class AgentState:
self.allow_shell = allow_shell self.allow_shell = allow_shell
if allow_write is not None: if allow_write is not None:
self.allow_write = allow_write 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 { return {
'model': self.model, 'model': self.model,
'base_url': self.base_url, 'base_url': self.base_url,
'cwd': str(self.cwd), '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_shell': self.allow_shell,
'allow_write': self.allow_write, 'allow_write': self.allow_write,
'active_session_id': self.agent.active_session_id, 'active_session_id': agent.active_session_id,
} }
def lock(self) -> Lock: def lock(self) -> Lock:
@@ -138,7 +181,10 @@ class AgentState:
class ChatRequest(BaseModel): class ChatRequest(BaseModel):
prompt: str = Field(min_length=1) prompt: str = Field(min_length=1)
runtime_context: str | None = None
resume_session_id: str | None = None resume_session_id: str | None = None
account_id: str | None = None
session_id: str | None = None
class StateUpdate(BaseModel): class StateUpdate(BaseModel):
@@ -148,6 +194,12 @@ class StateUpdate(BaseModel):
cwd: str | None = None cwd: str | None = None
allow_shell: bool | None = None allow_shell: bool | None = None
allow_write: 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 ------------------------------------------------------ # ------------- info ------------------------------------------------------
@app.get('/api/state') @app.get('/api/state')
async def get_state() -> dict[str, Any]: async def get_state(account_id: str | None = None) -> dict[str, Any]:
return state.snapshot() return state.snapshot(account_id)
@app.post('/api/state') @app.post('/api/state')
async def post_state(payload: StateUpdate) -> dict[str, Any]: 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)) state.update(**payload.model_dump(exclude_none=True))
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) raise HTTPException(status_code=400, detail=str(exc))
return state.snapshot() return state.snapshot(payload.account_id)
@app.get('/api/slash-commands') @app.get('/api/slash-commands')
async def list_slash_commands() -> list[dict[str, Any]]: async def list_slash_commands() -> list[dict[str, Any]]:
@@ -208,45 +260,62 @@ def create_app(state: AgentState) -> FastAPI:
if skill.user_invocable 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 -------------------------------------------------- # ------------- sessions --------------------------------------------------
@app.get('/api/sessions') @app.get('/api/sessions')
async def list_sessions() -> list[dict[str, Any]]: async def list_sessions(account_id: str | None = None) -> list[dict[str, Any]]:
directory = state.session_directory directory = state.account_paths(account_id)['sessions']
if not directory.exists(): if not directory.exists():
return [] return []
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
for path in sorted( for path in sorted(_iter_session_files(directory), key=_session_mtime, reverse=True):
directory.glob('*.json'),
key=lambda p: p.stat().st_mtime,
reverse=True,
):
try: try:
data = json.loads(path.read_text(encoding='utf-8')) data = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
continue 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 [] messages = data.get('messages') or []
preview = '' preview = ''
for msg in messages: for msg in messages:
if isinstance(msg, dict) and msg.get('role') == 'user': if isinstance(msg, dict) and msg.get('role') == 'user':
content = msg.get('content', '') content = msg.get('content', '')
if isinstance(content, str): if isinstance(content, str) and not _is_internal_message(content):
preview = content[:120] preview = _strip_session_context(content)[:120]
break break
results.append( results.append(
{ {
'session_id': data.get('session_id', path.stem), 'session_id': session_id,
'turns': data.get('turns', 0), 'turns': data.get('turns', 0),
'tool_calls': data.get('tool_calls', 0), 'tool_calls': data.get('tool_calls', 0),
'preview': preview, 'preview': preview,
'modified_at': path.stat().st_mtime, 'modified_at': _session_mtime(path),
'model': model_config.get('model'),
'usage': usage,
} }
) )
return results return results
@app.get('/api/sessions/{session_id}') @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: try:
stored = load_agent_session(session_id, directory=state.session_directory) stored = load_agent_session(session_id, directory=directory)
except FileNotFoundError: except FileNotFoundError:
raise HTTPException(status_code=404, detail='Session not found') raise HTTPException(status_code=404, detail='Session not found')
return _serialize_stored_session(stored) return _serialize_stored_session(stored)
@@ -260,22 +329,42 @@ def create_app(state: AgentState) -> FastAPI:
def _run() -> dict[str, Any]: def _run() -> dict[str, Any]:
with state.lock(): 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: if request.resume_session_id is not None:
try: try:
stored = load_agent_session( stored = load_agent_session(
request.resume_session_id, request.resume_session_id,
directory=state.session_directory, directory=session_directory,
) )
except FileNotFoundError: except FileNotFoundError:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail='Session to resume not found', detail='Session to resume not found',
) )
result = agent.resume(prompt, stored) result = agent.resume(
prompt,
stored,
runtime_context=request.runtime_context,
)
else: else:
result = agent.run(prompt) result = agent.run(
return _serialize_run_result(result) 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: try:
payload = await asyncio.to_thread(_run) payload = await asyncio.to_thread(_run)
@@ -292,10 +381,10 @@ def create_app(state: AgentState) -> FastAPI:
return payload return payload
@app.post('/api/clear') @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(): with state.lock():
state.agent.clear_runtime_state() state.agent_for(account_id).clear_runtime_state()
return state.snapshot() return state.snapshot(account_id)
return app return app
@@ -338,3 +427,182 @@ def _serialize_stored_session(stored: StoredAgentSession) -> dict[str, Any]:
'total_cost_usd': stored.total_cost_usd, 'total_cost_usd': stored.total_cost_usd,
'model': stored.model_config.get('model'), '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('<system-reminder>')
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
-1
View File
@@ -1 +0,0 @@
productionize
-4
View File
@@ -1,4 +0,0 @@
node_modules
.next
.git
.env
+4
View File
@@ -0,0 +1,4 @@
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# chat persistence -- sign up on cloud.assistant-ui.com
# NEXT_PUBLIC_ASSISTANT_BASE_URL=
+38 -23
View File
@@ -1,30 +1,45 @@
# Logs # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
logs
*.log # 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* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* .pnpm-debug.log*
lerna-debug.log*
node_modules # env files (can opt-in for committing if needed)
dist .env*
dist-ssr !.env.example
*.local
# Editor directories and files # vercel
.vscode/* .vercel
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# LangGraph API # typescript
.langgraph_api *.tsbuildinfo
.env
.next/
next-env.d.ts next-env.d.ts
# Turborepo
.turbo
-6
View File
@@ -1,6 +0,0 @@
# Ignore artifacts:
build
coverage
#
pnpm-lock.yaml
-21
View File
@@ -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.
+21 -15
View File
@@ -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. First, add your OpenAI API key to `.env.local` file:
- 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.
Upstream license attribution is kept in `LICENSE.agent-chat-ui`. ```
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
## Development
```bash
corepack enable
pnpm install
pnpm dev
``` ```
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.
+314
View File
@@ -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<UIMessage>,
transcript?: ClawTranscriptEntry[],
) {
if (!transcript?.length) return;
const toolResults = new Map<string, string>();
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<UIMessage["parts"][number], { type: "file" }>,
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<ClawChatResponse> {
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()
);
}
@@ -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 },
);
}
}
@@ -0,0 +1,6 @@
import { logoutAccount } from "@/lib/claw-auth";
export async function POST() {
await logoutAccount();
return Response.json({ ok: true });
}
@@ -0,0 +1,5 @@
import { getCurrentAccount } from "@/lib/claw-auth";
export async function GET() {
return Response.json({ account: await getCurrentAccount() });
}
@@ -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 },
);
}
}
+48
View File
@@ -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";
}
+53
View File
@@ -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 },
);
}
}
@@ -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",
},
});
}
@@ -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",
},
});
}
+20
View File
@@ -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",
},
});
}
+58
View File
@@ -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 },
);
}
}
+127
View File
@@ -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<string, unknown>;
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 (
<div className="flex min-h-dvh items-center justify-center bg-background text-muted-foreground text-sm">
...
</div>
);
}
if (!account) {
return <ClawAccountGate onAccount={setAccount} />;
}
return (
<AssistantRuntimeProvider runtime={runtime}>
<ClawSessionReplayProvider value={{ replaySession }}>
<ActivityProvider>
<SidebarProvider>
<div className="flex h-dvh w-full pr-0.5">
<ThreadListSidebar
account={account}
onLogout={() => setAccount(null)}
/>
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger />
<Separator orientation="vertical" className="mr-2 h-4" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-sm"></div>
<div className="text-muted-foreground text-xs">
Claw Data Agent
</div>
</div>
<ClawLlmSettings />
</header>
<div className="flex min-h-0 flex-1 overflow-hidden">
<div className="min-w-0 flex-1 overflow-hidden">
<Thread />
</div>
<ActivityPanel />
</div>
</SidebarInset>
</div>
</SidebarProvider>
</ActivityProvider>
</ClawSessionReplayProvider>
</AssistantRuntimeProvider>
);
};
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;
}
+150
View File
@@ -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<ClawAccount | null>(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 (
<div className="flex min-h-dvh items-center justify-center bg-background px-4">
<div className="w-full max-w-sm rounded-xl border bg-card p-6 shadow-sm">
<div className="mb-6">
<h1 className="font-semibold text-xl">Claw Data Agent</h1>
<p className="mt-1 text-muted-foreground text-sm">
</p>
</div>
<div className="grid gap-3">
<Input
autoFocus
placeholder="账号"
value={username}
onChange={(event) => setUsername(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") submit();
}}
/>
<Input
type="password"
placeholder="密码"
value={password}
onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") submit();
}}
/>
{error ? <p className="text-destructive text-sm">{error}</p> : null}
<Button onClick={submit} disabled={isSubmitting}>
{isSubmitting ? "处理中..." : mode === "login" ? "登录" : "注册"}
</Button>
<button
type="button"
className="text-muted-foreground text-sm hover:text-foreground"
onClick={() => {
setError("");
setMode((current) =>
current === "login" ? "register" : "login",
);
}}
>
{mode === "login" ? "没有账号,去注册" : "已有账号,去登录"}
</button>
</div>
</div>
</div>
);
}
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 (
<div className="ml-auto flex items-center gap-2">
{children}
<span className="max-w-32 truncate text-muted-foreground text-sm">
{account.username}
</span>
<Button variant="ghost" size="icon" onClick={logout} title="退出登录">
<LogOutIcon className="size-4" />
<span className="sr-only">退</span>
</Button>
</div>
);
}
+150
View File
@@ -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<ClawState>(DEFAULT_STATE);
const [status, setStatus] = useState<string>("");
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Settings2Icon />
LLM API
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle> LLM API</DialogTitle>
<DialogDescription>
Claw Agent 使 Base URL API Key
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<Field label="Base URL">
<Input
value={state.base_url}
onChange={(event) =>
setState((current) => ({
...current,
base_url: event.target.value,
}))
}
/>
</Field>
<Field label="API Key">
<Input
type="password"
value={state.api_key ?? ""}
onChange={(event) =>
setState((current) => ({
...current,
api_key: event.target.value,
}))
}
/>
</Field>
</div>
{status ? (
<p className="text-muted-foreground text-sm">{status}</p>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => setState(DEFAULT_STATE)}>
</Button>
<Button onClick={saveState} disabled={isSaving}>
{isSaving ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="grid gap-2 text-sm">
<span className="font-medium">{label}</span>
{children}
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -1,117 +1,125 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *)); @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 { :root {
--radius: 0.625rem;
--background: oklch(1 0 0); --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: 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: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.205 0 0); --primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0 0); --muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.97 0 0); --accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325); --border: oklch(0.92 0.004 286.32);
--border: oklch(0.922 0 0); --input: oklch(0.92 0.004 286.32);
--input: oklch(0.922 0 0); --ring: oklch(0.705 0.015 286.067);
--ring: oklch(0.87 0 0);
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429); --chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08); --chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.87 0 0); --sidebar-ring: oklch(0.705 0.015 286.067);
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0); --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); --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); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0); --primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.269 0 0); --accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723); --destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.637 0.237 25.331); --border: oklch(1 0 0 / 10%);
--border: oklch(0.269 0 0); --input: oklch(1 0 0 / 15%);
--input: oklch(0.269 0 0); --ring: oklch(0.552 0.016 285.938);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9); --chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439); --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-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0); --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-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0); --sidebar-ring: oklch(0.552 0.016 285.938);
}
@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);
} }
@layer base { @layer base {
@@ -119,33 +127,15 @@
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
:root {
color-scheme: light;
}
:root.dark {
color-scheme: dark;
}
body { body {
@apply bg-background text-foreground; @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);
}
} }
+35
View File
@@ -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 (
<html lang="zh-CN">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<TooltipProvider>{children}</TooltipProvider>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { Assistant } from "./assistant";
export default function Home() {
return <Assistant />;
}
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"$schema": "https://ui.shadcn.com/schema.json", "$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york", "style": "new-york",
"rsc": false, "rsc": true,
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "tailwind.config.js", "config": "",
"css": "src/index.css", "css": "app/globals.css",
"baseColor": "neutral", "baseColor": "zinc",
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
}, },
@@ -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<ActivityContextValue | null>(null);
export function ActivityProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(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 (
<ActivityContext.Provider value={value}>
{children}
</ActivityContext.Provider>
);
}
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 (
<aside className="fixed inset-y-0 right-0 z-30 flex w-[min(23rem,calc(100vw-1rem))] flex-col border-l bg-background shadow-xl lg:static lg:z-auto lg:h-full lg:w-92 lg:shrink-0 lg:shadow-none">
<header className="flex h-16 shrink-0 items-center justify-between border-b px-4">
<div className="min-w-0">
<h2 className="font-semibold text-base"></h2>
<p className="text-muted-foreground text-xs">
{items.length > 0 ? `${items.length} 个步骤` : "暂无链路"}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={close}
aria-label="关闭活动面板"
>
<PanelRightCloseIcon className="size-4" />
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{items.length === 0 ? (
<p className="text-muted-foreground text-sm">
</p>
) : (
<div className="flex flex-col gap-2">
{items.map((item) => (
<ActivityPanelItem
key={item.id}
item={item}
open={selectedId === item.id}
onOpenChange={(nextOpen) => {
if (nextOpen) openItem(item.id);
}}
/>
))}
</div>
)}
</div>
</aside>
);
}
function ActivityPanelItem({
item,
open,
onOpenChange,
}: {
item: ActivityItem;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const Icon = item.kind === "tool" ? WrenchIcon : BrainIcon;
return (
<Collapsible
open={open}
onOpenChange={onOpenChange}
className="rounded-lg border bg-card text-card-foreground"
>
<CollapsibleTrigger className="flex w-full items-start gap-3 px-3 py-3 text-left">
<ActivityStatusIcon status={item.status} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate font-medium text-sm">{item.title}</span>
</div>
<p className="mt-1 line-clamp-2 text-muted-foreground text-xs">
{item.summary}
</p>
</div>
<ChevronDownIcon
className={cn(
"mt-0.5 size-4 shrink-0 text-muted-foreground transition-transform",
!open && "-rotate-90",
)}
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="flex flex-col gap-3 border-t px-3 py-3 text-xs">
{item.argsText ? (
<ActivityPre title="输入参数" value={item.argsText} />
) : null}
{item.resultSummary ? (
<ActivityResultSummary value={item.resultSummary} />
) : null}
{item.fileLinks?.length ? (
<ActivityFileLinks paths={item.fileLinks} />
) : null}
{item.rawResult ? (
<Collapsible>
<CollapsibleTrigger className="flex items-center gap-1 font-medium text-muted-foreground transition-colors hover:text-foreground">
<ChevronDownIcon className="size-3" />
</CollapsibleTrigger>
<CollapsibleContent>
<ActivityPre title="" value={item.rawResult} className="mt-2" />
</CollapsibleContent>
</Collapsible>
) : null}
{item.kind === "reasoning" ? (
<p className="text-muted-foreground">
</p>
) : null}
</div>
</CollapsibleContent>
</Collapsible>
);
}
function ActivityStatusIcon({ status }: { status: ActivityItem["status"] }) {
if (status === "running") {
return <ClockIcon className="mt-0.5 size-4 shrink-0 animate-pulse" />;
}
if (status === "incomplete") {
return <XCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />;
}
return (
<CheckCircle2Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
);
}
function ActivityPre({
title,
value,
className,
}: {
title: string;
value: string;
className?: string;
}) {
return (
<div className={className}>
{title ? (
<p className="mb-1 font-medium text-muted-foreground">{title}</p>
) : null}
<pre className="max-h-80 overflow-auto rounded-md bg-muted px-3 py-2 whitespace-pre-wrap">
{value}
</pre>
</div>
);
}
function ActivityResultSummary({ value }: { value: string }) {
return (
<div className="rounded-md border bg-muted/30 px-3 py-2">
<div className="mb-1 flex items-center gap-1.5 font-medium text-muted-foreground">
<FileTextIcon className="size-3.5" />
</div>
<p className="whitespace-pre-wrap">{value}</p>
</div>
);
}
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 (
<div className="rounded-md border bg-muted/30 px-3 py-2">
<div className="mb-1 flex items-center gap-1.5 font-medium text-muted-foreground">
<FileTextIcon className="size-3.5" />
</div>
<div className="flex flex-col gap-1">
{paths.map((filePath) => (
<a
key={filePath}
href={`/api/claw/files?path=${encodeURIComponent(filePath)}`}
className="truncate text-primary hover:underline"
target="_blank"
rel="noreferrer"
title={filePath}
>
{basename(filePath)}
</a>
))}
</div>
</div>
);
}
function getPartStatus(
message: Extract<MessageState, { role: "assistant" }>,
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<MessageState, { role: "assistant" }>,
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<string, unknown>;
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<string>();
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;
}
@@ -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<string | undefined>(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<AttachmentPreviewProps> = ({ src }) => {
const [isLoaded, setIsLoaded] = useState(false);
return (
<img
src={src}
alt="Attachment preview"
className={cn(
"block h-auto max-h-[80vh] w-auto max-w-full object-contain",
isLoaded
? "aui-attachment-preview-image-loaded"
: "aui-attachment-preview-image-loading invisible",
)}
onLoad={() => setIsLoaded(true)}
/>
);
};
const AttachmentPreviewDialog: FC<PropsWithChildren> = ({ children }) => {
const src = useAttachmentSrc();
if (!src) return children;
return (
<Dialog>
<DialogTrigger
className="aui-attachment-preview-trigger cursor-pointer transition-colors hover:bg-accent/50"
asChild
>
{children}
</DialogTrigger>
<DialogContent className="aui-attachment-preview-dialog-content p-2 sm:max-w-3xl [&>button]:rounded-full [&>button]:bg-foreground/60 [&>button]:p-1 [&>button]:opacity-100 [&>button]:ring-0! [&_svg]:text-background [&>button]:hover:[&_svg]:text-destructive">
<DialogTitle className="aui-sr-only sr-only">
Image Attachment Preview
</DialogTitle>
<div className="aui-attachment-preview relative mx-auto flex max-h-[80dvh] w-full items-center justify-center overflow-hidden bg-background">
<AttachmentPreview src={src} />
</div>
</DialogContent>
</Dialog>
);
};
const AttachmentThumb: FC = () => {
const src = useAttachmentSrc();
return (
<Avatar className="aui-attachment-tile-avatar h-full w-full rounded-none">
<AvatarImage
src={src}
alt="Attachment preview"
className="aui-attachment-tile-image object-cover"
/>
<AvatarFallback>
<FileText className="aui-attachment-tile-fallback-icon size-8 text-muted-foreground" />
</AvatarFallback>
</Avatar>
);
};
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 (
<Tooltip>
<AttachmentPrimitive.Root
className={cn(
"aui-attachment-root relative",
isImage && "aui-attachment-root-composer only:*:first:size-24",
)}
>
<AttachmentPreviewDialog>
<TooltipTrigger asChild>
<div
className="aui-attachment-tile size-14 cursor-pointer overflow-hidden rounded-[calc(var(--composer-radius)-var(--composer-padding))] border bg-muted transition-opacity hover:opacity-75"
role="button"
tabIndex={0}
aria-label={`${typeLabel} attachment`}
>
<AttachmentThumb />
</div>
</TooltipTrigger>
</AttachmentPreviewDialog>
{isComposer && <AttachmentRemove />}
</AttachmentPrimitive.Root>
<TooltipContent side="top">
<AttachmentPrimitive.Name />
</TooltipContent>
</Tooltip>
);
};
const AttachmentRemove: FC = () => {
return (
<AttachmentPrimitive.Remove asChild>
<TooltipIconButton
tooltip="Remove file"
className="aui-attachment-tile-remove absolute top-1.5 right-1.5 size-3.5 rounded-full bg-white text-muted-foreground opacity-100 shadow-sm hover:bg-white! [&_svg]:text-black hover:[&_svg]:text-destructive"
side="top"
>
<XIcon className="aui-attachment-remove-icon size-3 dark:stroke-[2.5px]" />
</TooltipIconButton>
</AttachmentPrimitive.Remove>
);
};
export const UserMessageAttachments: FC = () => {
return (
<div className="aui-user-message-attachments-end col-span-full col-start-1 row-start-1 flex w-full flex-row justify-end gap-2">
<MessagePrimitive.Attachments>
{() => <AttachmentUI />}
</MessagePrimitive.Attachments>
</div>
);
};
export const ComposerAttachments: FC = () => {
return (
<div className="aui-composer-attachments flex w-full flex-row items-center gap-2 overflow-x-auto empty:hidden">
<ComposerPrimitive.Attachments>
{() => <AttachmentUI />}
</ComposerPrimitive.Attachments>
</div>
);
};
export const ComposerAddAttachment: FC = () => {
return (
<ComposerPrimitive.AddAttachment asChild>
<TooltipIconButton
tooltip="Add Attachment"
side="bottom"
variant="ghost"
size="icon"
className="aui-composer-add-attachment size-8 rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
aria-label="Add Attachment"
>
<PlusIcon className="aui-attachment-add-icon size-5 stroke-[1.5px]" />
</TooltipIconButton>
</ComposerPrimitive.AddAttachment>
);
};
@@ -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 (
<MarkdownTextPrimitive
remarkPlugins={[remarkGfm]}
className="aui-md"
components={defaultComponents}
/>
);
};
export const MarkdownText = memo(MarkdownTextImpl);
const CodeHeader: FC<CodeHeaderProps> = ({ language, code }) => {
const { isCopied, copyToClipboard } = useCopyToClipboard();
const onCopy = () => {
if (!code || isCopied) return;
copyToClipboard(code);
};
return (
<div className="aui-code-header-root mt-2.5 flex items-center justify-between rounded-t-lg border border-border/50 border-b-0 bg-muted/50 px-3 py-1.5 text-xs">
<span className="aui-code-header-language font-medium text-muted-foreground lowercase">
{language}
</span>
<TooltipIconButton tooltip="Copy" onClick={onCopy}>
{!isCopied && <CopyIcon />}
{isCopied && <CheckIcon />}
</TooltipIconButton>
</div>
);
};
const useCopyToClipboard = ({
copiedDuration = 3000,
}: {
copiedDuration?: number;
} = {}) => {
const [isCopied, setIsCopied] = useState<boolean>(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 }) => (
<h1
className={cn(
"aui-md-h1 mb-2 scroll-m-20 font-semibold text-base first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h2: ({ className, ...props }) => (
<h2
className={cn(
"aui-md-h2 mt-3 mb-1.5 scroll-m-20 font-semibold text-sm first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h3: ({ className, ...props }) => (
<h3
className={cn(
"aui-md-h3 mt-2.5 mb-1 scroll-m-20 font-semibold text-sm first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h4: ({ className, ...props }) => (
<h4
className={cn(
"aui-md-h4 mt-2 mb-1 scroll-m-20 font-medium text-sm first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h5: ({ className, ...props }) => (
<h5
className={cn(
"aui-md-h5 mt-2 mb-1 font-medium text-sm first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h6: ({ className, ...props }) => (
<h6
className={cn(
"aui-md-h6 mt-2 mb-1 font-medium text-sm first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
p: ({ className, ...props }) => (
<p
className={cn(
"aui-md-p my-2.5 leading-normal first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
a: ({ className, ...props }) => (
<a
className={cn(
"aui-md-a text-primary underline underline-offset-2 hover:text-primary/80",
className,
)}
{...props}
/>
),
blockquote: ({ className, ...props }) => (
<blockquote
className={cn(
"aui-md-blockquote my-2.5 border-muted-foreground/30 border-l-2 pl-3 text-muted-foreground italic",
className,
)}
{...props}
/>
),
ul: ({ className, ...props }) => (
<ul
className={cn(
"aui-md-ul my-2 ml-4 list-disc marker:text-muted-foreground [&>li]:mt-1",
className,
)}
{...props}
/>
),
ol: ({ className, ...props }) => (
<ol
className={cn(
"aui-md-ol my-2 ml-4 list-decimal marker:text-muted-foreground [&>li]:mt-1",
className,
)}
{...props}
/>
),
hr: ({ className, ...props }) => (
<hr
className={cn("aui-md-hr my-2 border-muted-foreground/20", className)}
{...props}
/>
),
table: ({ className, ...props }) => (
<table
className={cn(
"aui-md-table my-2 w-full border-separate border-spacing-0 overflow-y-auto",
className,
)}
{...props}
/>
),
th: ({ className, ...props }) => (
<th
className={cn(
"aui-md-th bg-muted px-2 py-1 text-left font-medium first:rounded-tl-lg last:rounded-tr-lg [[align=center]]:text-center [[align=right]]:text-right",
className,
)}
{...props}
/>
),
td: ({ className, ...props }) => (
<td
className={cn(
"aui-md-td border-muted-foreground/20 border-b border-l px-2 py-1 text-left last:border-r [[align=center]]:text-center [[align=right]]:text-right",
className,
)}
{...props}
/>
),
tr: ({ className, ...props }) => (
<tr
className={cn(
"aui-md-tr m-0 border-b p-0 first:border-t [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg",
className,
)}
{...props}
/>
),
li: ({ className, ...props }) => (
<li className={cn("aui-md-li leading-normal", className)} {...props} />
),
sup: ({ className, ...props }) => (
<sup
className={cn("aui-md-sup [&>a]:text-xs [&>a]:no-underline", className)}
{...props}
/>
),
pre: ({ className, ...props }) => (
<pre
className={cn(
"aui-md-pre overflow-x-auto rounded-t-none rounded-b-lg border border-border/50 border-t-0 bg-muted/30 p-3 text-xs leading-relaxed",
className,
)}
{...props}
/>
),
code: function Code({ className, ...props }) {
const isCodeBlock = useIsMarkdownCodeBlock();
return (
<code
className={cn(
!isCodeBlock &&
"aui-md-inline-code rounded-md border border-border/50 bg-muted/50 px-1.5 py-0.5 font-mono text-[0.85em]",
className,
)}
{...props}
/>
);
},
CodeHeader,
});
@@ -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<typeof Collapsible>,
"open" | "onOpenChange"
> &
VariantProps<typeof reasoningVariants> & {
open?: boolean;
onOpenChange?: (open: boolean) => void;
defaultOpen?: boolean;
};
function ReasoningRoot({
className,
variant,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
defaultOpen = false,
children,
...props
}: ReasoningRootProps) {
const collapsibleRef = useRef<HTMLDivElement>(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 (
<Collapsible
ref={collapsibleRef}
data-slot="reasoning-root"
data-variant={variant}
open={isOpen}
onOpenChange={handleOpenChange}
className={cn(
"group/reasoning-root",
reasoningVariants({ variant, className }),
)}
style={
{
"--animation-duration": `${ANIMATION_DURATION}ms`,
} as React.CSSProperties
}
{...props}
>
{children}
</Collapsible>
);
}
function ReasoningFade({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="reasoning-fade"
className={cn(
"aui-reasoning-fade pointer-events-none absolute inset-x-0 bottom-0 z-10 h-8",
"bg-[linear-gradient(to_top,var(--color-background),transparent)]",
"group-data-[variant=muted]/reasoning-root:bg-[linear-gradient(to_top,hsl(var(--muted)/0.5),transparent)]",
"fade-in-0 animate-in",
"group-data-[state=open]/collapsible-content:animate-out",
"group-data-[state=open]/collapsible-content:fade-out-0",
"group-data-[state=open]/collapsible-content:delay-[calc(var(--animation-duration)*0.75)]",
"group-data-[state=open]/collapsible-content:fill-mode-forwards",
"duration-(--animation-duration)",
"group-data-[state=open]/collapsible-content:duration-(--animation-duration)",
className,
)}
{...props}
/>
);
}
function ReasoningTrigger({
active,
duration,
className,
...props
}: React.ComponentProps<typeof CollapsibleTrigger> & {
active?: boolean;
duration?: number;
}) {
const durationText = duration ? ` (${duration}s)` : "";
return (
<CollapsibleTrigger
data-slot="reasoning-trigger"
className={cn(
"aui-reasoning-trigger group/trigger flex max-w-[75%] items-center gap-2 py-1 text-muted-foreground text-sm transition-colors hover:text-foreground",
className,
)}
{...props}
>
<BrainIcon
data-slot="reasoning-trigger-icon"
className="aui-reasoning-trigger-icon size-4 shrink-0"
/>
<span
data-slot="reasoning-trigger-label"
className="aui-reasoning-trigger-label-wrapper relative inline-block leading-none"
>
<span>Reasoning{durationText}</span>
{active ? (
<span
aria-hidden
data-slot="reasoning-trigger-shimmer"
className="aui-reasoning-trigger-shimmer shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none"
>
Reasoning{durationText}
</span>
) : null}
</span>
<ChevronDownIcon
data-slot="reasoning-trigger-chevron"
className={cn(
"aui-reasoning-trigger-chevron mt-0.5 size-4 shrink-0",
"transition-transform duration-(--animation-duration) ease-out",
"group-data-[state=closed]/trigger:-rotate-90",
"group-data-[state=open]/trigger:rotate-0",
)}
/>
</CollapsibleTrigger>
);
}
function ReasoningContent({
className,
children,
...props
}: React.ComponentProps<typeof CollapsibleContent>) {
return (
<CollapsibleContent
data-slot="reasoning-content"
className={cn(
"aui-reasoning-content relative overflow-hidden text-muted-foreground text-sm outline-none",
"group/collapsible-content ease-out",
"data-[state=closed]:animate-collapsible-up",
"data-[state=open]:animate-collapsible-down",
"data-[state=closed]:fill-mode-forwards",
"data-[state=closed]:pointer-events-none",
"data-[state=open]:duration-(--animation-duration)",
"data-[state=closed]:duration-(--animation-duration)",
className,
)}
{...props}
>
{children}
<ReasoningFade />
</CollapsibleContent>
);
}
function ReasoningText({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="reasoning-text"
className={cn(
"aui-reasoning-text relative z-0 max-h-64 space-y-4 overflow-y-auto pt-2 pb-2 pl-6 leading-relaxed",
"transform-gpu transition-[transform,opacity]",
"group-data-[state=open]/collapsible-content:animate-in",
"group-data-[state=closed]/collapsible-content:animate-out",
"group-data-[state=open]/collapsible-content:fade-in-0",
"group-data-[state=closed]/collapsible-content:fade-out-0",
"group-data-[state=open]/collapsible-content:slide-in-from-top-4",
"group-data-[state=closed]/collapsible-content:slide-out-to-top-4",
"group-data-[state=open]/collapsible-content:duration-(--animation-duration)",
"group-data-[state=closed]/collapsible-content:duration-(--animation-duration)",
className,
)}
{...props}
/>
);
}
const ReasoningImpl: ReasoningMessagePartComponent = () => <MarkdownText />;
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 (
<ReasoningRoot defaultOpen={isReasoningStreaming}>
<ReasoningTrigger active={isReasoningStreaming} />
<ReasoningContent aria-busy={isReasoningStreaming}>
<ReasoningText>{children}</ReasoningText>
</ReasoningContent>
</ReasoningRoot>
);
};
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,
};
@@ -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 (
<ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex flex-col gap-1">
<ThreadListNew />
<AuiIf condition={({ threads }) => threads.isLoading}>
<ThreadListSkeleton />
</AuiIf>
<AuiIf condition={({ threads }) => !threads.isLoading}>
<ThreadListPrimitive.Items>
{() => <ThreadListItem />}
</ThreadListPrimitive.Items>
</AuiIf>
<ClawSessionList />
</ThreadListPrimitive.Root>
);
};
const ThreadListNew: FC = () => {
return (
<div
onClickCapture={() => {
window.localStorage.removeItem("claw.activeSessionId");
window.dispatchEvent(new Event("claw-active-session-cleared"));
}}
>
<ThreadListPrimitive.New asChild>
<Button
variant="outline"
className="aui-thread-list-new h-9 w-full justify-start gap-2 rounded-lg px-3 text-sm hover:bg-muted data-active:bg-muted"
>
<PlusIcon className="size-4" />
New Thread
</Button>
</ThreadListPrimitive.New>
</div>
);
};
const ThreadListSkeleton: FC = () => {
const skeletonKeys = ["one", "two", "three", "four", "five"];
return (
<div className="flex flex-col gap-1">
{skeletonKeys.map((key) => (
<div
key={key}
role="status"
aria-label="Loading threads"
className="aui-thread-list-skeleton-wrapper flex h-9 items-center px-3"
>
<Skeleton className="aui-thread-list-skeleton h-4 w-full" />
</div>
))}
</div>
);
};
const ThreadListItem: FC = () => {
return (
<ThreadListItemPrimitive.Root className="aui-thread-list-item group flex h-9 items-center gap-2 rounded-lg transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none data-active:bg-muted">
<ThreadListItemPrimitive.Trigger className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center truncate px-3 text-start text-sm">
<ThreadListItemPrimitive.Title fallback="New Chat" />
</ThreadListItemPrimitive.Trigger>
<ThreadListItemMore />
</ThreadListItemPrimitive.Root>
);
};
const ThreadListItemMore: FC = () => {
return (
<ThreadListItemMorePrimitive.Root>
<ThreadListItemMorePrimitive.Trigger asChild>
<Button
variant="ghost"
size="icon"
className="aui-thread-list-item-more mr-2 size-7 p-0 opacity-0 transition-opacity group-hover:opacity-100 data-[state=open]:bg-accent data-[state=open]:opacity-100 group-data-active:opacity-100"
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More options</span>
</Button>
</ThreadListItemMorePrimitive.Trigger>
<ThreadListItemMorePrimitive.Content
side="bottom"
align="start"
className="aui-thread-list-item-more-content z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
>
<ThreadListItemPrimitive.Archive asChild>
<ThreadListItemMorePrimitive.Item className="aui-thread-list-item-more-item flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
<ArchiveIcon className="size-4" />
Archive
</ThreadListItemMorePrimitive.Item>
</ThreadListItemPrimitive.Archive>
</ThreadListItemMorePrimitive.Content>
</ThreadListItemMorePrimitive.Root>
);
};
const ClawSessionList: FC = () => {
const { replaySession } = useClawSessionReplay();
const [sessions, setSessions] = useState<ClawSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(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 (
<div className="mt-4 border-t pt-3">
<div className="mb-2 flex items-center justify-between gap-2 px-3">
<div className="flex items-center gap-2 font-medium text-muted-foreground text-xs">
<HistoryIcon className="size-3.5" />
Claw
</div>
{activeSessionId ? (
<button
type="button"
className="text-muted-foreground text-xs hover:text-foreground"
onClick={() => {
window.localStorage.removeItem("claw.activeSessionId");
setActiveSessionId(null);
}}
>
</button>
) : null}
</div>
<div className="flex flex-col gap-1">
{sessions.map((session) => {
const active = activeSessionId === session.session_id;
return (
<button
key={`claw-session-${session.session_id}-${session.modified_at}`}
type="button"
className="rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-muted data-[active=true]:bg-muted"
data-active={active}
onClick={async () => {
window.localStorage.setItem(
"claw.activeSessionId",
session.session_id,
);
setActiveSessionId(session.session_id);
setLoadingSessionId(session.session_id);
try {
const response = await fetch(
`/api/claw/sessions/${session.session_id}`,
);
if (!response.ok) return;
const payload = (await response.json()) as ClawStoredSession;
replaySession(
session.session_id,
toReplayRepository(payload, session.session_id),
);
} finally {
setLoadingSessionId(null);
}
}}
>
<span className="block truncate">
{session.preview || session.session_id}
</span>
<span className="mt-0.5 block truncate text-muted-foreground text-xs">
{active ? "下一条消息将续接此会话 · " : ""}
{loadingSessionId === session.session_id ? "回放中 · " : ""}
{session.turns} · {session.tool_calls}
</span>
</button>
);
})}
</div>
</div>
);
};
function dedupeSessions(payload: unknown[]) {
const byId = new Map<string, ClawSession>();
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<ClawSession>;
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("<system-reminder>")) 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<string, string>();
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<string, string>,
) {
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;
}
}
File diff suppressed because it is too large Load Diff
@@ -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<typeof Sidebar> & {
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 (
<Sidebar {...props}>
<SidebarHeader className="aui-sidebar-header mb-2 border-b">
<div className="aui-sidebar-header-content flex items-center justify-between">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg">
<div className="aui-sidebar-header-icon-wrapper flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<BotIcon className="aui-sidebar-header-icon size-4" />
</div>
<div className="aui-sidebar-header-heading mr-6 flex flex-col gap-0.5 leading-none">
<span className="aui-sidebar-header-title font-semibold">
Claw Data Agent
</span>
<span className="text-muted-foreground text-xs">
</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</div>
</SidebarHeader>
<SidebarContent className="aui-sidebar-content px-2">
<ThreadList />
</SidebarContent>
<SidebarRail />
<SidebarFooter className="aui-sidebar-footer border-t">
<AccountMenu account={account} onLogout={onLogout} />
</SidebarFooter>
</Sidebar>
);
}
function AccountMenu({
account,
onLogout,
}: {
account: ClawAccount;
onLogout: () => void;
}) {
const [open, setOpen] = useState(false);
const [state, setState] = useState<ClawState | null>(null);
const [sessions, setSessions] = useState<ClawSessionSummary[]>([]);
const [activeSession, setActiveSession] = useState<ClawStoredSession | null>(
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 (
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
<PopoverPrimitive.Trigger asChild>
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors hover:bg-sidebar-accent"
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<UserIcon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-sm">
{account.username}
</div>
<div className="text-muted-foreground text-xs"></div>
</div>
</button>
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
side="top"
align="start"
sideOffset={8}
className="z-50 w-80 rounded-lg border bg-popover p-3 text-popover-foreground shadow-md outline-none"
>
<div className="mb-3 flex items-center gap-2">
<div className="flex size-9 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<UserIcon className="size-4" />
</div>
<div className="min-w-0">
<div className="truncate font-medium">{account.username}</div>
<div className="truncate text-muted-foreground text-xs">
{state?.model ?? "模型配置读取中"}
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<AccountStat label="会话" value={sessions.length} />
<AccountStat label="工具" value={totalToolCalls} />
<AccountStat
label="Token"
value={activeSession?.usage?.total_tokens ?? "暂无"}
/>
</div>
<div className="mt-3 rounded-md border bg-muted/30 p-2 text-xs">
<div className="mb-1 flex items-center gap-1.5 font-medium">
<BarChart3Icon className="size-3.5" />
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-muted-foreground">
<span> token</span>
<span className="text-right">
{activeSession?.usage?.input_tokens ?? "暂无"}
</span>
<span> token</span>
<span className="text-right">
{activeSession?.usage?.output_tokens ?? "暂无"}
</span>
<span></span>
<span className="text-right">
{activeSession?.tool_calls ?? "暂无"}
</span>
</div>
</div>
<div className="mt-3 rounded-md border bg-muted/30 p-2 text-xs">
<button
type="button"
className="flex w-full items-center justify-between gap-2 text-left"
onClick={() => setModelUsageOpen((value) => !value)}
>
<span className="font-medium"></span>
<span className="flex items-center gap-1 text-muted-foreground">
{modelUsage.length
? `${modelUsage.length} 个模型 · ${formatCompactNumber(modelUsageTotalTokens)} tokens`
: "暂无"}
<ChevronDownIcon
className={`size-3.5 transition-transform ${
modelUsageOpen ? "rotate-180" : ""
}`}
/>
</span>
</button>
{modelUsageOpen && modelUsage.length ? (
<div className="mt-2 flex max-h-48 flex-col gap-2 overflow-y-auto pr-1">
{modelUsage.map((item) => (
<div
key={item.model}
className="grid grid-cols-[1fr_auto] gap-2"
>
<span className="truncate text-muted-foreground">
{item.model}
</span>
<span>{formatCompactNumber(item.totalTokens)} tokens</span>
<span className="text-muted-foreground">
{item.sessions} · {item.toolCalls}
</span>
<span className="text-right text-muted-foreground">
in {formatCompactNumber(item.inputTokens)} / out{" "}
{formatCompactNumber(item.outputTokens)}
</span>
</div>
))}
</div>
) : null}
{modelUsageOpen && !modelUsage.length ? (
<div className="text-muted-foreground"></div>
) : null}
</div>
<Button
variant="ghost"
className="mt-3 w-full justify-start gap-2 text-muted-foreground"
onClick={logout}
>
<LogOutIcon className="size-4" />
退
</Button>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
);
}
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 (
<div className="rounded-md border bg-muted/30 px-2 py-1.5">
<div className="font-medium text-sm">{value}</div>
<div className="text-muted-foreground text-xs">{label}</div>
</div>
);
}
@@ -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<typeof Collapsible>,
"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<HTMLDivElement>(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 (
<Collapsible
ref={collapsibleRef}
data-slot="tool-fallback-root"
open={isOpen}
onOpenChange={handleOpenChange}
className={cn(
"aui-tool-fallback-root group/tool-fallback-root w-full rounded-lg border py-3",
className,
)}
style={
{
"--animation-duration": `${ANIMATION_DURATION}ms`,
} as React.CSSProperties
}
{...props}
>
{children}
</Collapsible>
);
}
type ToolStatus = ToolCallMessagePartStatus["type"];
const statusIconMap: Record<ToolStatus, React.ElementType> = {
running: LoaderIcon,
complete: CheckIcon,
incomplete: XCircleIcon,
"requires-action": AlertCircleIcon,
};
function ToolFallbackTrigger({
toolName,
status,
className,
...props
}: React.ComponentProps<typeof CollapsibleTrigger> & {
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 (
<CollapsibleTrigger
data-slot="tool-fallback-trigger"
className={cn(
"aui-tool-fallback-trigger group/trigger flex w-full items-center gap-2 px-4 text-sm transition-colors",
className,
)}
{...props}
>
<Icon
data-slot="tool-fallback-trigger-icon"
className={cn(
"aui-tool-fallback-trigger-icon size-4 shrink-0",
isCancelled && "text-muted-foreground",
isRunning && "animate-spin",
)}
/>
<span
data-slot="tool-fallback-trigger-label"
className={cn(
"aui-tool-fallback-trigger-label-wrapper relative inline-block grow text-left leading-none",
isCancelled && "text-muted-foreground line-through",
)}
>
<span>
{label}: <b>{toolName}</b>
</span>
{isRunning && (
<span
aria-hidden
data-slot="tool-fallback-trigger-shimmer"
className="aui-tool-fallback-trigger-shimmer shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none"
>
{label}: <b>{toolName}</b>
</span>
)}
</span>
<ChevronDownIcon
data-slot="tool-fallback-trigger-chevron"
className={cn(
"aui-tool-fallback-trigger-chevron size-4 shrink-0",
"transition-transform duration-(--animation-duration) ease-out",
"group-data-[state=closed]/trigger:-rotate-90",
"group-data-[state=open]/trigger:rotate-0",
)}
/>
</CollapsibleTrigger>
);
}
function ToolFallbackContent({
className,
children,
...props
}: React.ComponentProps<typeof CollapsibleContent>) {
return (
<CollapsibleContent
data-slot="tool-fallback-content"
className={cn(
"aui-tool-fallback-content relative overflow-hidden text-sm outline-none",
"group/collapsible-content ease-out",
"data-[state=closed]:animate-collapsible-up",
"data-[state=open]:animate-collapsible-down",
"data-[state=closed]:fill-mode-forwards",
"data-[state=closed]:pointer-events-none",
"data-[state=open]:duration-(--animation-duration)",
"data-[state=closed]:duration-(--animation-duration)",
className,
)}
{...props}
>
<div className="mt-3 flex flex-col gap-2 border-t pt-2">{children}</div>
</CollapsibleContent>
);
}
function ToolFallbackArgs({
argsText,
className,
...props
}: React.ComponentProps<"div"> & {
argsText?: string;
}) {
if (!argsText) return null;
return (
<div
data-slot="tool-fallback-args"
className={cn("aui-tool-fallback-args px-4", className)}
{...props}
>
<pre className="aui-tool-fallback-args-value whitespace-pre-wrap">
{argsText}
</pre>
</div>
);
}
function ToolFallbackResult({
result,
className,
...props
}: React.ComponentProps<"div"> & {
result?: unknown;
}) {
if (result === undefined) return null;
return (
<div
data-slot="tool-fallback-result"
className={cn(
"aui-tool-fallback-result border-t border-dashed px-4 pt-2",
className,
)}
{...props}
>
<p className="aui-tool-fallback-result-header font-semibold">Result:</p>
<pre className="aui-tool-fallback-result-content whitespace-pre-wrap">
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
</pre>
</div>
);
}
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 (
<div
data-slot="tool-fallback-error"
className={cn("aui-tool-fallback-error px-4", className)}
{...props}
>
<p className="aui-tool-fallback-error-header font-semibold text-muted-foreground">
{headerText}
</p>
<p className="aui-tool-fallback-error-reason text-muted-foreground">
{errorText}
</p>
</div>
);
}
const ToolFallbackImpl: ToolCallMessagePartComponent = ({
toolName,
argsText,
result,
status,
}) => {
const isCancelled =
status?.type === "incomplete" && status.reason === "cancelled";
return (
<ToolFallbackRoot
className={cn(isCancelled && "border-muted-foreground/30 bg-muted/30")}
>
<ToolFallbackTrigger toolName={toolName} status={status} />
<ToolFallbackContent>
<ToolFallbackError status={status} />
<ToolFallbackArgs
argsText={argsText}
className={cn(isCancelled && "opacity-60")}
/>
{!isCancelled && <ToolFallbackResult result={result} />}
</ToolFallbackContent>
</ToolFallbackRoot>
);
};
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,
};
@@ -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<typeof Button> & {
tooltip: string;
side?: "top" | "bottom" | "left" | "right";
};
export const TooltipIconButton = forwardRef<
HTMLButtonElement,
TooltipIconButtonProps
>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
{...rest}
className={cn("aui-button-icon size-6 p-1", className)}
ref={ref}
>
<Slot.Slottable>{children}</Slot.Slottable>
<span className="aui-sr-only sr-only">{tooltip}</span>
</Button>
</TooltipTrigger>
<TooltipContent side={side}>{tooltip}</TooltipContent>
</Tooltip>
);
});
TooltipIconButton.displayName = "TooltipIconButton";
+7
View File
@@ -0,0 +1,7 @@
export function GitHubIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
);
}
+109
View File
@@ -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<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg";
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 select-none overflow-hidden rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground text-sm group-data-[size=sm]/avatar:text-xs",
className,
)}
{...props}
/>
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex select-none items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>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 (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className,
)}
{...props}
/>
);
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground text-sm ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>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,
};
+109
View File
@@ -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 <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-muted-foreground text-sm sm:gap-2.5",
className,
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
};
+64
View File
@@ -0,0 +1,64 @@
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
@@ -0,0 +1,33 @@
"use client";
import { Collapsible as CollapsiblePrimitive } from "radix-ui";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleContent, CollapsibleTrigger };
+157
View File
@@ -0,0 +1,157 @@
"use client";
import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg outline-none duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("font-semibold text-lg leading-none", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+21
View File
@@ -0,0 +1,21 @@
import type * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Input };
+28
View File
@@ -0,0 +1,28 @@
"use client";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };
+139
View File
@@ -0,0 +1,139 @@
"use client";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
};
+724
View File
@@ -0,0 +1,724 @@
"use client";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className,
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className,
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
className,
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 font-medium text-sidebar-foreground/70 text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 font-medium text-sidebar-foreground text-xs tabular-nums",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-sidebar-border border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
);
}
export { Skeleton };
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in text-balance rounded-md bg-foreground px-3 py-1.5 text-background text-xs data-[state=closed]:animate-out",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
-33
View File
@@ -1,33 +0,0 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": [
"warn",
{ args: "none", argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
);
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}
+193
View File
@@ -0,0 +1,193 @@
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { cookies } from "next/headers";
const AUTH_ROOT = path.join(
/* turbopackIgnore: true */ process.cwd(),
"../../.port_sessions/accounts",
);
const USERS_PATH = path.join(AUTH_ROOT, "users.json");
const SESSIONS_PATH = path.join(AUTH_ROOT, "auth_sessions.json");
const COOKIE_NAME = "claw_account_session";
type UserRecord = {
id: string;
username: string;
passwordHash: string;
createdAt: string;
};
type UsersFile = {
users: UserRecord[];
};
type SessionsFile = {
sessions: Record<string, { accountId: string; createdAt: string }>;
};
export type AccountSession = {
id: string;
username: string;
};
export async function registerAccount(username: string, password: string) {
const cleanUsername = normalizeUsername(username);
validatePassword(password);
const usersFile = await readUsers();
if (usersFile.users.some((user) => user.username === cleanUsername)) {
throw new Error("账号已存在");
}
const account: UserRecord = {
id: cleanUsername,
username: cleanUsername,
passwordHash: hashPassword(password),
createdAt: new Date().toISOString(),
};
usersFile.users.push(account);
await writeUsers(usersFile);
await createAccountDirectories(account.id);
return createSession(account);
}
export async function loginAccount(username: string, password: string) {
const cleanUsername = normalizeUsername(username);
const usersFile = await readUsers();
const account = usersFile.users.find(
(user) => user.username === cleanUsername,
);
if (!account || !verifyPassword(password, account.passwordHash)) {
throw new Error("账号或密码错误");
}
await createAccountDirectories(account.id);
return createSession(account);
}
export async function logoutAccount() {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (token) {
const sessionsFile = await readSessions();
delete sessionsFile.sessions[token];
await writeSessions(sessionsFile);
}
cookieStore.delete(COOKIE_NAME);
}
export async function getCurrentAccount(): Promise<AccountSession | null> {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) return null;
const sessionsFile = await readSessions();
const session = sessionsFile.sessions[token];
if (!session) return null;
const usersFile = await readUsers();
const account = usersFile.users.find((user) => user.id === session.accountId);
if (!account) return null;
return { id: account.id, username: account.username };
}
export function accountUploadRoot(accountId: string) {
return path.join(AUTH_ROOT, accountId, "sessions");
}
export function accountOutputRoot(accountId: string) {
return path.join(AUTH_ROOT, accountId, "sessions");
}
export function accountBaseRoot(accountId: string) {
return path.join(AUTH_ROOT, accountId);
}
export function accountSessionInputRoot(accountId: string, sessionId: string) {
return path.join(accountBaseRoot(accountId), "sessions", sessionId, "input");
}
export function accountSessionOutputRoot(accountId: string, sessionId: string) {
return path.join(accountBaseRoot(accountId), "sessions", sessionId, "output");
}
async function createSession(account: UserRecord) {
const token = randomBytes(32).toString("hex");
const sessionsFile = await readSessions();
sessionsFile.sessions[token] = {
accountId: account.id,
createdAt: new Date().toISOString(),
};
await writeSessions(sessionsFile);
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, token, {
httpOnly: true,
sameSite: "lax",
path: "/",
});
return { id: account.id, username: account.username };
}
async function createAccountDirectories(accountId: string) {
await Promise.all(
["sessions"].map((name) =>
mkdir(path.join(AUTH_ROOT, accountId, name), { recursive: true }),
),
);
}
async function readUsers(): Promise<UsersFile> {
await mkdir(AUTH_ROOT, { recursive: true });
try {
return JSON.parse(await readFile(USERS_PATH, "utf8")) as UsersFile;
} catch {
return { users: [] };
}
}
async function writeUsers(payload: UsersFile) {
await mkdir(AUTH_ROOT, { recursive: true });
await writeFile(USERS_PATH, JSON.stringify(payload, null, 2), "utf8");
}
async function readSessions(): Promise<SessionsFile> {
await mkdir(AUTH_ROOT, { recursive: true });
try {
return JSON.parse(await readFile(SESSIONS_PATH, "utf8")) as SessionsFile;
} catch {
return { sessions: {} };
}
}
async function writeSessions(payload: SessionsFile) {
await mkdir(AUTH_ROOT, { recursive: true });
await writeFile(SESSIONS_PATH, JSON.stringify(payload, null, 2), "utf8");
}
function normalizeUsername(username: string) {
const cleanUsername = username.trim().toLowerCase();
if (!/^[a-z0-9._-]{2,40}$/.test(cleanUsername)) {
throw new Error(
"账号只能包含小写字母、数字、点、下划线或中划线,长度 2-40",
);
}
return cleanUsername;
}
function validatePassword(password: string) {
if (password.length < 6) throw new Error("密码至少 6 位");
}
function hashPassword(password: string) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(password, salt, 64).toString("hex");
return `${salt}:${hash}`;
}
function verifyPassword(password: string, passwordHash: string) {
const [salt, expectedHash] = passwordHash.split(":");
if (!salt || !expectedHash) return false;
const actual = Buffer.from(scryptSync(password, salt, 64).toString("hex"));
const expected = Buffer.from(expectedHash);
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import type { ExportedMessageRepository } from "@assistant-ui/core";
import { createContext, useContext } from "react";
type ClawSessionReplayContextValue = {
replaySession: (
sessionId: string,
repository: ExportedMessageRepository,
) => void;
};
const ClawSessionReplayContext =
createContext<ClawSessionReplayContextValue | null>(null);
export const ClawSessionReplayProvider = ClawSessionReplayContext.Provider;
export function useClawSessionReplay() {
const value = useContext(ClawSessionReplayContext);
if (!value) {
throw new Error(
"useClawSessionReplay must be used within ClawSessionReplayProvider",
);
}
return value;
}
@@ -1,4 +1,4 @@
import { clsx, type ClassValue } from "clsx"; import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
-10
View File
@@ -1,10 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: {
bodySizeLimit: "10mb",
},
},
};
export default nextConfig;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+7322
View File
File diff suppressed because it is too large Load Diff
+22 -80
View File
@@ -1,106 +1,48 @@
{ {
"name": "claw-code-agent-frontend", "name": "app",
"readme": "./README.md",
"homepage": "https://github.com/HarnessLab/claw-code-agent",
"repository": {
"type": "git",
"url": "git+https://github.com/HarnessLab/claw-code-agent.git"
},
"private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "biome check app/assistant.tsx app/claw-account-gate.tsx app/claw-llm-settings.tsx app/api components/assistant-ui/thread.tsx components/assistant-ui/thread-list.tsx components/assistant-ui/threadlist-sidebar.tsx components/assistant-ui/activity-panel.tsx hooks lib next.config.ts package.json tsconfig.json postcss.config.mjs components.json",
"lint:fix": "next lint --fix", "lint:fix": "biome check --write app/assistant.tsx app/claw-account-gate.tsx app/claw-llm-settings.tsx app/api components/assistant-ui/thread.tsx components/assistant-ui/thread-list.tsx components/assistant-ui/threadlist-sidebar.tsx components/assistant-ui/activity-panel.tsx hooks lib next.config.ts package.json tsconfig.json postcss.config.mjs components.json",
"format": "prettier --write .", "format": "biome format app/assistant.tsx app/claw-account-gate.tsx app/claw-llm-settings.tsx app/api components/assistant-ui/thread.tsx components/assistant-ui/thread-list.tsx components/assistant-ui/threadlist-sidebar.tsx components/assistant-ui/activity-panel.tsx hooks lib next.config.ts package.json tsconfig.json postcss.config.mjs components.json",
"format:check": "prettier --check ." "format:fix": "biome format --write app/assistant.tsx app/claw-account-gate.tsx app/claw-llm-settings.tsx app/api components/assistant-ui/thread.tsx components/assistant-ui/thread-list.tsx components/assistant-ui/threadlist-sidebar.tsx components/assistant-ui/activity-panel.tsx hooks lib next.config.ts package.json tsconfig.json postcss.config.mjs components.json"
}, },
"dependencies": { "dependencies": {
"@langchain/core": "^1.1.44", "@ai-sdk/openai": "^3.0.53",
"@langchain/langgraph": "^1.2.9", "@assistant-ui/react": "latest",
"@langchain/langgraph-sdk": "^1.8.10", "@assistant-ui/react-ai-sdk": "latest",
"@assistant-ui/react-markdown": "latest",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"ai": "^6.0.168",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "lucide-react": "^1.12.0",
"esbuild": "^0.28.0", "next": "^16.2.4",
"esbuild-plugin-tailwindcss": "^2.2.0", "radix-ui": "^1.4.3",
"framer-motion": "^12.38.0",
"katex": "^0.16.45",
"langgraph-nextjs-api-passthrough": "^0.1.4",
"lodash": "^4.18.1",
"lucide-react": "^0.476.0",
"next-themes": "^0.4.6",
"nuqs": "^2.8.9",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.1",
"recharts": "^2.15.3",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.4", "tw-shimmer": "^0.4.11",
"uuid": "^14.0.0", "zustand": "^5.0.12"
"zod": "^4.4.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.27.0", "@biomejs/biome": "^2.4.13",
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.2.4", "@tailwindcss/postcss": "^4.2.4",
"@types/lodash": "^4.17.24", "@types/node": "^25.6.0",
"@types/node": "^22.15.18",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^10.0.0",
"autoprefixer": "^10.5.0",
"dotenv": "^16.5.0",
"eslint": "^9.39.3",
"eslint-config-next": "16.2.4",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"next": "^15.5.15",
"postcss": "^8.5.13",
"prettier": "^3.8.3",
"prettier-plugin-tailwindcss": "^0.8.0",
"tailwind-scrollbar": "^4.0.2",
"tailwindcss": "^4.2.4", "tailwindcss": "^4.2.4",
"typescript": "~5.8.3", "typescript": "^6.0.3"
"typescript-eslint": "^8.59.1"
},
"overrides": {
"react-is": "^19.0.0-rc-69d4b800-20241021"
},
"pnpm": {
"overrides": {
"@eslint/config-array>minimatch": "^3.1.3",
"eslint>minimatch": "^3.1.3",
"eslint-plugin-import>minimatch": "^3.1.3",
"eslint-plugin-jsx-a11y>minimatch": "^3.1.3",
"eslint-plugin-react>minimatch": "^3.1.3",
"@typescript-eslint/typescript-estree>minimatch": "^9.0.6",
"micromatch>picomatch": "^2.3.2",
"tinyglobby>picomatch": "^4.0.4",
"flat-cache>flatted": "^3.4.2",
"minimatch@9>brace-expansion": "^5.0.5",
"eslint>ajv": "^6.14.0",
"mdast-util-to-hast": "^13.2.1",
"refractor>prismjs": "^1.30.0"
} }
},
"packageManager": "pnpm@10.5.1"
} }
-7728
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,5 +1,5 @@
export default { const config = {
plugins: { plugins: ["@tailwindcss/postcss"],
"@tailwindcss/postcss": {},
},
}; };
export default config;
-11
View File
@@ -1,11 +0,0 @@
/**
* @see https://prettier.io/docs/configuration
* @type {import("prettier").Config}
*/
const config = {
endOfLine: "auto",
singleAttributePerLine: true,
plugins: ["prettier-plugin-tailwindcss"],
};
export default config;
@@ -1,11 +0,0 @@
import { initApiPassthrough } from "langgraph-nextjs-api-passthrough";
// This file acts as a proxy for requests to your LangGraph server.
// Read the [Going to Production](https://github.com/langchain-ai/agent-chat-ui?tab=readme-ov-file#going-to-production) section for more information.
export const { GET, POST, PUT, PATCH, DELETE, OPTIONS, runtime } =
initApiPassthrough({
apiUrl: process.env.LANGGRAPH_API_URL ?? "remove-me", // default, if not defined it will attempt to read process.env.LANGGRAPH_API_URL
apiKey: process.env.LANGSMITH_API_KEY ?? "remove-me", // default, if not defined it will attempt to read process.env.LANGSMITH_API_KEY
runtime: "edge", // default
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

-30
View File
@@ -1,30 +0,0 @@
import type { Metadata } from "next";
import "./globals.css";
import { Inter } from "next/font/google";
import React from "react";
import { NuqsAdapter } from "nuqs/adapters/next/app";
const inter = Inter({
subsets: ["latin"],
preload: true,
display: "swap",
});
export const metadata: Metadata = {
title: "Agent Chat",
description: "Agent Chat UX by LangChain",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<NuqsAdapter>{children}</NuqsAdapter>
</body>
</html>
);
}
-23
View File
@@ -1,23 +0,0 @@
"use client";
import { Thread } from "@/components/thread";
import { StreamProvider } from "@/providers/Stream";
import { ThreadProvider } from "@/providers/Thread";
import { ArtifactProvider } from "@/components/thread/artifact";
import { Toaster } from "@/components/ui/sonner";
import React from "react";
export default function DemoPage(): React.ReactNode {
return (
<React.Suspense fallback={<div>Loading (layout)...</div>}>
<Toaster />
<ThreadProvider>
<StreamProvider>
<ArtifactProvider>
<Thread />
</ArtifactProvider>
</StreamProvider>
</ThreadProvider>
</React.Suspense>
);
}
@@ -1,12 +0,0 @@
export const GitHubSVG = ({ width = "100%", height = "100%" }) => (
<svg
role="img"
viewBox="0 0 24 24"
width={width}
height={height}
xmlns="http://www.w3.org/2000/svg"
>
<title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
);
@@ -1,102 +0,0 @@
export function LangGraphLogoSVG({
className,
width,
height,
}: {
width?: number;
height?: number;
className?: string;
}) {
return (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 3000 554"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_7880_26096)">
<path
d="M1599.44 227.244H1598.94C1589.71 205.732 1563.05 182.656 1518.96 182.656C1447.21 182.656 1395.95 232.889 1395.95 316.953C1395.95 401.018 1447.71 452.28 1519.45 452.28C1565.07 452.28 1588.64 429.738 1598.9 404.641H1599.4V436.413C1599.4 481.535 1576.32 503.047 1534.82 503.047C1499.96 503.047 1476.92 490.727 1470.78 462.044H1406.21C1413.38 514.337 1461.59 553.28 1535.89 553.28C1606.11 553.28 1668.62 517.884 1668.62 427.183V189.827H1599.47V227.244H1599.44ZM1533.79 400.522C1486.12 400.522 1462.01 361.045 1462.01 317.487C1462.01 273.929 1486.61 234.987 1533.79 234.987C1580.97 234.987 1605.54 279.574 1605.54 317.487C1605.54 355.4 1586.05 400.522 1533.79 400.522Z"
fill="#030710"
/>
<path
d="M1088.87 294.449C1088.87 229.341 1058.13 181.664 975.056 181.664C905.333 181.664 869.022 227.358 861.317 270.878H923.336C930.01 246.276 949.501 230.905 975.628 230.905C1005.84 230.905 1024.3 243.187 1024.3 264.203C1024.3 286.745 1007.9 291.894 973.568 297.005C936.647 302.612 849.531 309.782 849.531 382.595C849.531 427.182 884.393 462.044 942.292 462.044C992.525 462.044 1014.57 435.383 1025.33 414.405H1025.86V427.716C1025.86 436.413 1026.39 446.177 1027.92 454.873H1094.02C1090.4 437.938 1088.91 410.781 1088.91 390.299V294.449H1088.87ZM1025.33 331.904C1025.33 393.427 994.088 413.375 960.257 413.375C930.544 413.375 915.135 399.53 915.135 379.543C915.135 359.557 931.04 348.801 966.398 341.592C1005.34 333.926 1019.19 322.14 1025.33 309.324V331.866V331.904Z"
fill="#030710"
/>
<path
d="M1274.43 182.695C1233.43 182.695 1209.89 204.703 1194.98 235.483V189.866H1130.41V454.875H1196.51V335.949C1196.51 271.375 1220.61 240.099 1255.97 240.099C1293.92 240.099 1304.14 270.345 1304.14 308.792V454.875H1369.75V292.887C1369.75 227.817 1343.08 182.695 1274.39 182.695H1274.43Z"
fill="#030710"
/>
<path
d="M1890.27 140.664C1939.97 140.664 1973.8 160.65 1993.29 203.712H2060.95C2038.41 128.878 1982 82.2305 1890.27 82.2305C1779.05 82.2305 1703.68 161.222 1703.68 272.405C1703.68 383.588 1778.51 461.55 1889.73 461.512C1980.47 461.512 2042.49 412.843 2060.95 340.03H1992.79C1977.92 378.973 1946.14 403.079 1890.27 403.079C1819.51 403.079 1772.37 347.735 1772.37 272.405C1772.37 197.075 1819.02 140.702 1890.27 140.702V140.664Z"
fill="#030710"
/>
<path
d="M2904.65 182.695C2863.64 182.695 2840.11 204.703 2825.2 235.483V189.866H2760.62V454.875H2826.72V335.949C2826.72 271.375 2850.83 240.099 2886.19 240.099C2924.1 240.099 2934.36 270.345 2934.36 308.792V454.875H2999.96V292.887C2999.96 227.817 2973.3 182.695 2904.61 182.695H2904.65Z"
fill="#030710"
/>
<path
d="M2716.03 111.676H2647.88V173.198H2716.03V111.676Z"
fill="#030710"
/>
<path
d="M2648.91 235.751V454.837H2715.01V189.828H2694.79C2669.43 189.828 2648.91 210.387 2648.91 235.751Z"
fill="#030710"
/>
<path
d="M2242.39 182.62C2201.39 182.62 2177.85 204.665 2162.94 234.912V88.8672H2098.37V454.761H2164.47V335.835C2164.47 271.261 2188.57 239.985 2223.93 239.985C2261.88 239.985 2272.1 270.231 2272.1 308.678V454.761H2337.71V292.773C2337.71 227.703 2311.05 182.581 2242.35 182.581L2242.39 182.62Z"
fill="#030710"
/>
<path
d="M2605.84 294.449C2605.84 229.341 2575.1 181.664 2492.03 181.664C2422.31 181.664 2386 227.358 2378.33 270.878H2440.35C2447.02 246.276 2466.51 230.905 2492.64 230.905C2522.85 230.905 2541.31 243.187 2541.31 264.203C2541.31 286.745 2524.91 291.894 2490.58 297.005C2453.66 302.612 2366.54 309.782 2366.54 382.595C2366.54 427.182 2401.4 462.044 2459.3 462.044C2509.54 462.044 2531.58 435.383 2542.34 414.405H2542.83V427.716C2542.83 436.413 2543.33 446.177 2544.89 454.873H2610.99C2607.41 437.938 2605.88 410.781 2605.88 390.299V294.449H2605.84ZM2542.26 331.904C2542.26 393.427 2511.02 413.375 2477.19 413.375C2447.48 413.375 2432.07 399.53 2432.07 379.543C2432.07 359.557 2447.98 348.801 2483.33 341.592C2522.28 333.926 2536.12 322.14 2542.26 309.324V331.866V331.904Z"
fill="#030710"
/>
<path
d="M657.602 333.318V88.8672H591.197V379.431H611.489C636.967 379.431 657.602 358.796 657.602 333.318Z"
fill="#030710"
/>
<path
d="M828.858 396.176H591.197V454.876H828.858V396.176Z"
fill="#030710"
/>
</g>
<g clip-path="url(#clip1_7880_26096)">
<path
d="M153.197 324.988C181.918 296.266 198.063 257.269 198.063 216.654C198.063 176.039 181.904 137.042 153.197 108.32L44.866 0C16.159 28.7218 0 67.7192 0 108.334C0 148.949 16.159 187.946 44.866 216.668L153.183 324.988H153.197Z"
fill="#030710"
/>
<path
d="M379.871 335.012C351.164 306.304 312.153 290.145 271.554 290.145C230.954 290.145 191.944 306.304 163.223 335.012L271.554 443.346C300.261 472.054 339.271 488.213 379.885 488.213C420.498 488.213 459.495 472.054 488.215 443.346L379.885 335.012H379.871Z"
fill="#030710"
/>
<path
d="M45.13 443.096C73.8509 471.804 112.847 487.963 153.461 487.963V334.762H0.25C0.263942 375.377 16.409 414.374 45.13 443.096Z"
fill="#030710"
/>
<path
d="M421.695 174.84C392.974 146.132 353.978 129.959 313.35 129.973C272.737 129.973 233.74 146.132 205.02 174.854L313.35 283.188L421.695 174.84Z"
fill="#030710"
/>
</g>
<defs>
<clipPath id="clip0_7880_26096">
<rect
width="2408.8"
height="471.05"
fill="white"
transform="translate(591.197 82.2305)"
/>
</clipPath>
<clipPath id="clip1_7880_26096">
<rect
width="488.214"
height="488.214"
fill="white"
/>
</clipPath>
</defs>
</svg>
);
}
@@ -1,37 +0,0 @@
import React from "react";
import { MultimodalPreview } from "./MultimodalPreview";
import { cn } from "@/lib/utils";
import { ContentBlock } from "@langchain/core/messages";
interface ContentBlocksPreviewProps {
blocks: ContentBlock.Multimodal.Data[];
onRemove: (idx: number) => void;
size?: "sm" | "md" | "lg";
className?: string;
}
/**
* Renders a preview of content blocks with optional remove functionality.
* Uses cn utility for robust class merging.
*/
export const ContentBlocksPreview: React.FC<ContentBlocksPreviewProps> = ({
blocks,
onRemove,
size = "md",
className,
}) => {
if (!blocks.length) return null;
return (
<div className={cn("flex flex-wrap gap-2 p-3.5 pb-0", className)}>
{blocks.map((block, idx) => (
<MultimodalPreview
key={idx}
block={block}
removable
onRemove={() => onRemove(idx)}
size={size}
/>
))}
</div>
);
};
@@ -1,115 +0,0 @@
import React from "react";
import { File, X as XIcon } from "lucide-react";
import { ContentBlock } from "@langchain/core/messages";
import { cn } from "@/lib/utils";
import Image from "next/image";
export interface MultimodalPreviewProps {
block: ContentBlock.Multimodal.Data;
removable?: boolean;
onRemove?: () => void;
className?: string;
size?: "sm" | "md" | "lg";
}
export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
block,
removable = false,
onRemove,
className,
size = "md",
}) => {
// Image block
if (
block.type === "image" &&
typeof block.mimeType === "string" &&
block.mimeType.startsWith("image/")
) {
const url = `data:${block.mimeType};base64,${block.data}`;
let imgClass: string = "rounded-md object-cover h-16 w-16 text-lg";
if (size === "sm") imgClass = "rounded-md object-cover h-10 w-10 text-base";
if (size === "lg") imgClass = "rounded-md object-cover h-24 w-24 text-xl";
return (
<div className={cn("relative inline-block", className)}>
<Image
src={url}
alt={String(block.metadata?.name || "uploaded image")}
className={imgClass}
width={size === "sm" ? 16 : size === "md" ? 32 : 48}
height={size === "sm" ? 16 : size === "md" ? 32 : 48}
/>
{removable && (
<button
type="button"
className="absolute top-1 right-1 z-10 rounded-full bg-gray-500 text-white hover:bg-gray-700"
onClick={onRemove}
aria-label="Remove image"
>
<XIcon className="h-4 w-4" />
</button>
)}
</div>
);
}
// PDF block
if (block.type === "file" && block.mimeType === "application/pdf") {
const filename =
block.metadata?.filename || block.metadata?.name || "PDF file";
return (
<div
className={cn(
"relative flex items-start gap-2 rounded-md border bg-gray-100 px-3 py-2",
className,
)}
>
<div className="flex flex-shrink-0 flex-col items-start justify-start">
<File
className={cn(
"text-teal-700",
size === "sm" ? "h-5 w-5" : "h-7 w-7",
)}
/>
</div>
<span
className={cn("min-w-0 flex-1 text-sm break-all text-gray-800")}
style={{ wordBreak: "break-all", whiteSpace: "pre-wrap" }}
>
{String(filename)}
</span>
{removable && (
<button
type="button"
className="ml-2 self-start rounded-full bg-gray-200 p-1 text-teal-700 hover:bg-gray-300"
onClick={onRemove}
aria-label="Remove PDF"
>
<XIcon className="h-4 w-4" />
</button>
)}
</div>
);
}
// Fallback for unknown types
return (
<div
className={cn(
"flex items-center gap-2 rounded-md border bg-gray-100 px-3 py-2 text-gray-500",
className,
)}
>
<File className="h-5 w-5 flex-shrink-0" />
<span className="truncate text-xs">Unsupported file type</span>
{removable && (
<button
type="button"
className="ml-2 rounded-full bg-gray-200 p-1 text-gray-500 hover:bg-gray-300"
onClick={onRemove}
aria-label="Remove file"
>
<XIcon className="h-4 w-4" />
</button>
)}
</div>
);
};
@@ -1,499 +0,0 @@
import React from "react";
import { DecisionWithEdits, SubmitType, HITLRequest } from "../types";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Undo2 } from "lucide-react";
import { MarkdownText } from "../../markdown-text";
import { haveArgsChanged, prettifyText } from "../utils";
import { toast } from "sonner";
function ResetButton({ handleReset }: { handleReset: () => void }) {
return (
<Button
onClick={handleReset}
variant="ghost"
className="flex items-center justify-center gap-2 text-gray-500 hover:text-red-500"
>
<Undo2 className="h-4 w-4" />
<span>Reset</span>
</Button>
);
}
function ArgsRenderer({ args }: { args: Record<string, unknown> }) {
return (
<div className="flex w-full flex-col items-start gap-6">
{Object.entries(args).map(([key, value]) => {
const stringValue =
typeof value === "string" || typeof value === "number"
? value.toString()
: JSON.stringify(value, null);
return (
<div
key={`args-${key}`}
className="flex flex-col items-start gap-1"
>
<p className="text-sm leading-[18px] text-wrap text-gray-600">
{prettifyText(key)}
</p>
<span className="w-full max-w-full rounded-xl bg-zinc-100 p-3 text-[13px] leading-[18px] text-black">
<MarkdownText>{stringValue}</MarkdownText>
</span>
</div>
);
})}
</div>
);
}
interface InboxItemInputProps {
interruptValue: HITLRequest;
humanResponse: DecisionWithEdits[];
supportsMultipleMethods: boolean;
approveAllowed: boolean;
hasEdited: boolean;
hasAddedResponse: boolean;
initialValues: Record<string, string>;
isLoading: boolean;
selectedSubmitType: SubmitType | undefined;
setHumanResponse: React.Dispatch<React.SetStateAction<DecisionWithEdits[]>>;
setSelectedSubmitType: React.Dispatch<
React.SetStateAction<SubmitType | undefined>
>;
setHasAddedResponse: React.Dispatch<React.SetStateAction<boolean>>;
setHasEdited: React.Dispatch<React.SetStateAction<boolean>>;
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void> | void;
}
function ApproveOnly({
isLoading,
actionRequestArgs,
handleSubmit,
}: {
isLoading: boolean;
actionRequestArgs: Record<string, unknown>;
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void> | void;
}) {
return (
<div className="flex w-full flex-col items-start gap-4 rounded-lg border border-gray-300 p-6">
{Object.keys(actionRequestArgs).length > 0 && (
<ArgsRenderer args={actionRequestArgs} />
)}
<Button
variant="brand"
disabled={isLoading}
onClick={handleSubmit}
className="w-full"
>
Approve
</Button>
</div>
);
}
function EditActionCard({
humanResponse,
isLoading,
initialValues,
onEditChange,
handleSubmit,
actionArgs,
}: {
humanResponse: DecisionWithEdits[];
isLoading: boolean;
initialValues: Record<string, string>;
actionArgs: Record<string, unknown>;
onEditChange: (
text: string | string[],
response: DecisionWithEdits,
key: string | string[],
) => void;
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void> | void;
}) {
const defaultRows = React.useRef<Record<string, number>>({});
const editResponse = humanResponse.find(
(response) => response.type === "edit",
);
const approveResponse = humanResponse.find(
(response) => response.type === "approve",
);
if (
!editResponse ||
editResponse.type !== "edit" ||
typeof editResponse.edited_action !== "object" ||
!editResponse.edited_action
) {
if (approveResponse) {
return (
<ApproveOnly
actionRequestArgs={actionArgs}
isLoading={isLoading}
handleSubmit={handleSubmit}
/>
);
}
return null;
}
const header = editResponse.acceptAllowed ? "Edit/Approve" : "Edit";
const buttonText =
editResponse.acceptAllowed && !editResponse.editsMade
? "Approve"
: "Submit";
const handleReset = () => {
if (!editResponse.edited_action?.args) {
return;
}
const keysToReset: string[] = [];
const valuesToReset: string[] = [];
Object.entries(initialValues).forEach(([key, value]) => {
if (key in editResponse.edited_action.args) {
const stringValue =
typeof value === "string" || typeof value === "number"
? value.toString()
: JSON.stringify(value, null);
keysToReset.push(key);
valuesToReset.push(stringValue);
}
});
if (keysToReset.length > 0 && valuesToReset.length > 0) {
onEditChange(valuesToReset, editResponse, keysToReset);
}
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
handleSubmit(event);
}
};
return (
<div className="flex w-full min-w-full flex-col items-start gap-4 rounded-lg border border-gray-300 p-6">
<div className="flex w-full items-center justify-between">
<p className="text-base font-semibold text-black">{header}</p>
<ResetButton handleReset={handleReset} />
</div>
{Object.entries(editResponse.edited_action.args).map(
([key, value], idx) => {
const stringValue =
typeof value === "string" || typeof value === "number"
? value.toString()
: JSON.stringify(value, null);
if (defaultRows.current[key] === undefined) {
defaultRows.current[key] = !stringValue.length
? 3
: Math.max(stringValue.length / 30, 7);
}
return (
<div
className="flex h-full w-full flex-col items-start gap-1 px-[1px]"
key={`allow-edit-args--${key}-${idx}`}
>
<div className="flex w-full flex-col items-start gap-[6px]">
<p className="min-w-fit text-sm font-medium">
{prettifyText(key)}
</p>
<Textarea
disabled={isLoading}
className="h-full w-full max-w-full"
value={stringValue}
onChange={(event) =>
onEditChange(event.target.value, editResponse, key)
}
onKeyDown={handleKeyDown}
rows={defaultRows.current[key] || 8}
/>
</div>
</div>
);
},
)}
<div className="flex w-full items-center justify-end gap-2">
<Button
variant="brand"
disabled={isLoading}
onClick={handleSubmit}
>
{buttonText}
</Button>
</div>
</div>
);
}
const EditAndApprove = React.memo(EditActionCard);
function RejectActionCard({
humanResponse,
isLoading,
onChange,
handleSubmit,
showArgs,
actionArgs,
}: {
humanResponse: DecisionWithEdits[];
isLoading: boolean;
onChange: (value: string, response: DecisionWithEdits) => void;
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void> | void;
showArgs: boolean;
actionArgs: Record<string, unknown>;
}) {
const rejectResponse = humanResponse.find(
(response) => response.type === "reject",
);
if (!rejectResponse) {
return null;
}
const handleKeyDown = (event: React.KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
handleSubmit(event);
}
};
return (
<div className="flex w-full max-w-full flex-col items-start gap-4 rounded-xl border border-gray-300 p-6">
<div className="flex w-full items-center justify-between">
<p className="text-base font-semibold text-black">Reject</p>
<ResetButton handleReset={() => onChange("", rejectResponse)} />
</div>
{showArgs && <ArgsRenderer args={actionArgs} />}
<div className="flex w-full flex-col items-start gap-[6px]">
<p className="min-w-fit text-sm font-medium">Reason</p>
<Textarea
disabled={isLoading}
className="w-full max-w-full"
value={rejectResponse.message ?? ""}
onChange={(event) => onChange(event.target.value, rejectResponse)}
onKeyDown={handleKeyDown}
rows={4}
placeholder="Share feedback with the agent..."
/>
</div>
<div className="flex w-full items-center justify-end gap-2">
<Button
variant="brand"
disabled={isLoading}
onClick={handleSubmit}
>
Submit rejection
</Button>
</div>
</div>
);
}
const RejectCard = React.memo(RejectActionCard);
export function InboxItemInput({
interruptValue,
humanResponse,
approveAllowed,
hasEdited,
hasAddedResponse,
initialValues,
isLoading,
supportsMultipleMethods,
selectedSubmitType,
setHumanResponse,
setSelectedSubmitType,
setHasAddedResponse,
setHasEdited,
handleSubmit,
}: InboxItemInputProps) {
const allowedDecisions =
interruptValue.review_configs?.[0]?.allowed_decisions ?? [];
const actionRequest = interruptValue.action_requests?.[0];
const actionArgs = actionRequest?.args ?? {};
const isEditAllowed = allowedDecisions.includes("edit");
const isRejectAllowed = allowedDecisions.includes("reject");
const hasArgs = Object.keys(actionArgs).length > 0;
const showArgsInReject =
hasArgs && !isEditAllowed && !approveAllowed && isRejectAllowed;
const showArgsOutsideCards =
hasArgs && !showArgsInReject && !isEditAllowed && !approveAllowed;
const onEditChange = (
change: string | string[],
response: DecisionWithEdits,
key: string | string[],
) => {
if (
(Array.isArray(change) && !Array.isArray(key)) ||
(!Array.isArray(change) && Array.isArray(key))
) {
toast.error("Error", {
description: "Unable to update edited values.",
richColors: true,
closeButton: true,
});
return;
}
let valuesChanged = true;
if (response.type === "edit" && response.edited_action) {
const updatedArgs = { ...(response.edited_action.args || {}) };
if (Array.isArray(change) && Array.isArray(key)) {
change.forEach((value, index) => {
if (index < key.length) {
updatedArgs[key[index]] = value;
}
});
} else {
updatedArgs[key as string] = change as string;
}
valuesChanged = haveArgsChanged(updatedArgs, initialValues);
}
if (!valuesChanged) {
setHasEdited(false);
if (approveAllowed) {
setSelectedSubmitType("approve");
} else if (hasAddedResponse) {
setSelectedSubmitType("reject");
}
} else {
setSelectedSubmitType("edit");
setHasEdited(true);
}
setHumanResponse((prev) => {
if (response.type !== "edit" || !response.edited_action) {
console.error("Mismatched response type for edit", response.type);
return prev;
}
const newArgs =
Array.isArray(change) && Array.isArray(key)
? {
...response.edited_action.args,
...Object.fromEntries(key.map((k, index) => [k, change[index]])),
}
: {
...response.edited_action.args,
[key as string]: change as string,
};
const newEdit: DecisionWithEdits = {
type: "edit",
edited_action: {
name: response.edited_action.name,
args: newArgs,
},
};
return prev.map((existing) => {
if (existing.type !== "edit") {
return existing;
}
if (existing.acceptAllowed) {
return {
...newEdit,
acceptAllowed: true,
editsMade: valuesChanged,
};
}
return newEdit;
});
});
};
const onRejectChange = (change: string, response: DecisionWithEdits) => {
if (response.type !== "reject") {
console.error("Mismatched response type for rejection");
return;
}
const trimmed = change.trim();
setHasAddedResponse(!!trimmed);
if (!trimmed) {
if (hasEdited) {
setSelectedSubmitType("edit");
} else if (approveAllowed) {
setSelectedSubmitType("approve");
}
} else {
setSelectedSubmitType("reject");
}
setHumanResponse((prev) =>
prev.map((existing) =>
existing.type === "reject"
? { type: "reject", message: change }
: existing,
),
);
};
return (
<div className="flex w-full max-w-full flex-col items-start justify-start gap-2">
{showArgsOutsideCards && <ArgsRenderer args={actionArgs} />}
<div className="flex w-full flex-col items-stretch gap-2">
<EditAndApprove
humanResponse={humanResponse}
isLoading={isLoading}
initialValues={initialValues}
actionArgs={actionArgs}
onEditChange={onEditChange}
handleSubmit={handleSubmit}
/>
{supportsMultipleMethods ? (
<div className="mx-auto mt-3 flex items-center gap-3">
<Separator className="w-full" />
<p className="text-sm text-gray-500">Or</p>
<Separator className="w-full" />
</div>
) : null}
<RejectCard
humanResponse={humanResponse}
isLoading={isLoading}
showArgs={showArgsInReject}
actionArgs={actionArgs}
onChange={onRejectChange}
handleSubmit={handleSubmit}
/>
{isLoading && (
<p className="text-sm text-gray-600">Submitting decision...</p>
)}
{selectedSubmitType && supportsMultipleMethods && (
<p className="text-xs text-gray-500">
Currently selected: {prettifyText(selectedSubmitType)}
</p>
)}
</div>
</div>
);
}
@@ -1,303 +0,0 @@
import { ChevronRight, X, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import { useEffect, useState } from "react";
import {
baseMessageObject,
isArrayOfMessages,
prettifyText,
unknownToPrettyDate,
} from "../utils";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
import { BaseMessage } from "@langchain/core/messages";
import { ToolCall } from "@langchain/core/messages/tool";
import { ToolCallTable } from "./tool-call-table";
import { Button } from "@/components/ui/button";
import { MarkdownText } from "../../markdown-text";
interface StateViewRecursiveProps {
value: unknown;
expanded?: boolean;
}
const messageTypeToLabel = (message: BaseMessage) => {
let type = "";
if ("type" in message) {
type = message.type as string;
} else if ("getType" in message) {
type = (message as BaseMessage).getType();
}
switch (type) {
case "human":
return "User";
case "ai":
return "Assistant";
case "tool":
return "Tool";
case "System":
return "System";
default:
return "";
}
};
function MessagesRenderer({ messages }: { messages: BaseMessage[] }) {
return (
<div className="flex w-full flex-col gap-1">
{messages.map((msg, idx) => {
const messageTypeLabel = messageTypeToLabel(msg);
const content =
typeof msg.content === "string"
? msg.content
: JSON.stringify(msg.content, null);
return (
<div
key={msg.id ?? `message-${idx}`}
className="ml-2 flex w-full flex-col gap-[2px]"
>
<p className="font-medium text-gray-700">{messageTypeLabel}:</p>
{content && <MarkdownText>{content}</MarkdownText>}
{"tool_calls" in msg && msg.tool_calls ? (
<div className="flex w-full flex-col items-start gap-1">
{(msg.tool_calls as ToolCall[]).map((tc, idx) => (
<ToolCallTable
key={tc.id ?? `tool-call-${idx}`}
toolCall={tc}
/>
))}
</div>
) : null}
</div>
);
})}
</div>
);
}
function StateViewRecursive(props: StateViewRecursiveProps) {
const date = unknownToPrettyDate(props.value);
if (date) {
return <p className="font-light text-gray-600">{date}</p>;
}
if (["string", "number"].includes(typeof props.value)) {
return <MarkdownText>{props.value as string}</MarkdownText>;
}
if (typeof props.value === "boolean") {
return <MarkdownText>{JSON.stringify(props.value)}</MarkdownText>;
}
if (props.value == null) {
return <p className="font-light whitespace-pre-wrap text-gray-600">null</p>;
}
if (Array.isArray(props.value)) {
if (props.value.length > 0 && isArrayOfMessages(props.value)) {
return <MessagesRenderer messages={props.value} />;
}
const valueArray = props.value as unknown[];
return (
<div className="flex w-full flex-row items-start justify-start gap-1">
<span className="font-normal text-black">[</span>
{valueArray.map((item, idx) => {
const itemRenderValue = baseMessageObject(item);
return (
<div
key={`state-view-${idx}`}
className="flex w-full flex-row items-start whitespace-pre-wrap"
>
<StateViewRecursive value={itemRenderValue} />
{idx < valueArray?.length - 1 && (
<span className="font-normal text-black">,&nbsp;</span>
)}
</div>
);
})}
<span className="font-normal text-black">]</span>
</div>
);
}
if (typeof props.value === "object") {
if (Object.keys(props.value).length === 0) {
return <p className="font-light text-gray-600">{"{}"}</p>;
}
return (
<div className="relative ml-6 flex w-full flex-col items-start justify-start gap-1">
{/* Vertical line */}
<div className="absolute top-0 left-[-24px] h-full w-[1px] bg-gray-200" />
{Object.entries(props.value).map(([key, value], idx) => (
<div
key={`state-view-object-${key}-${idx}`}
className="relative w-full"
>
{/* Horizontal connector line */}
<div className="absolute top-[10px] left-[-20px] h-[1px] w-[18px] bg-gray-200" />
<StateViewObject
expanded={props.expanded}
keyName={key}
value={value}
/>
</div>
))}
</div>
);
}
}
function HasContentsEllipsis({ onClick }: { onClick?: () => void }) {
return (
<span
onClick={onClick}
className={cn(
"rounded-md p-[2px] font-mono text-[10px] leading-3",
"bg-gray-50 text-gray-600 hover:bg-gray-100 hover:text-gray-800",
"cursor-pointer transition-colors ease-in-out",
"inline-block -translate-y-[2px]",
)}
>
{"{...}"}
</span>
);
}
interface StateViewProps {
keyName: string;
value: unknown;
/**
* Whether or not to expand or collapse the view
* @default true
*/
expanded?: boolean;
}
export function StateViewObject(props: StateViewProps) {
const [expanded, setExpanded] = useState(false);
useEffect(() => {
if (props.expanded != null) {
setExpanded(props.expanded);
}
}, [props.expanded]);
return (
<div className="relative flex flex-row items-start justify-start gap-2 text-sm">
<motion.div
initial={false}
animate={{ rotate: expanded ? 90 : 0 }}
transition={{ duration: 0.2 }}
>
<div
onClick={() => setExpanded((prev) => !prev)}
className="flex h-5 w-5 cursor-pointer items-center justify-center rounded-md text-gray-500 transition-colors ease-in-out hover:bg-gray-100 hover:text-black"
>
<ChevronRight className="h-4 w-4" />
</div>
</motion.div>
<div className="flex w-full flex-col items-start justify-start gap-1">
<p className="font-normal text-black">
{prettifyText(props.keyName)}{" "}
{!expanded && (
<HasContentsEllipsis onClick={() => setExpanded((prev) => !prev)} />
)}
</p>
<motion.div
initial={false}
animate={{
height: expanded ? "auto" : 0,
opacity: expanded ? 1 : 0,
}}
transition={{
duration: 0.2,
ease: "easeInOut",
}}
style={{ overflow: "hidden" }}
className="relative w-full"
>
<StateViewRecursive
expanded={props.expanded}
value={props.value}
/>
</motion.div>
</div>
</div>
);
}
interface StateViewComponentProps {
values: Record<string, any>;
description: string | undefined;
handleShowSidePanel: (showState: boolean, showDescription: boolean) => void;
view: "description" | "state";
}
export function StateView({
handleShowSidePanel,
view,
values,
description,
}: StateViewComponentProps) {
const [expanded, setExpanded] = useState(false);
if (!values) {
return <div>No state found</div>;
}
return (
<div
className={cn(
"flex min-w-full flex-row gap-0",
view === "state" &&
"border-t-[1px] border-gray-100 lg:border-t-[0px] lg:border-l-[1px]",
)}
>
{view === "description" && (
<div className="pt-6 pb-2">
<MarkdownText>
{description ?? "No description provided"}
</MarkdownText>
</div>
)}
{view === "state" && (
<div className="flex flex-col items-start justify-start gap-1">
{Object.entries(values).map(([k, v], idx) => (
<StateViewObject
expanded={expanded}
key={`state-view-${k}-${idx}`}
keyName={k}
value={v}
/>
))}
</div>
)}
<div className="flex items-start justify-end gap-2">
{view === "state" && (
<Button
onClick={() => setExpanded((prev) => !prev)}
variant="ghost"
className="text-gray-600"
size="sm"
>
{expanded ? (
<ChevronsUpDown className="h-4 w-4" />
) : (
<ChevronsDownUp className="h-4 w-4" />
)}
</Button>
)}
<Button
onClick={() => handleShowSidePanel(false, false)}
variant="ghost"
className="text-gray-600"
size="sm"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
);
}
@@ -1,449 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Interrupt } from "@langchain/langgraph-sdk";
import { Button } from "@/components/ui/button";
import { ThreadIdCopyable } from "./thread-id";
import { InboxItemInput } from "./inbox-item-input";
import useInterruptedActions from "../hooks/use-interrupted-actions";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { useQueryState } from "nuqs";
import { constructOpenInStudioURL, buildDecisionFromState } from "../utils";
import { Decision, HITLRequest, DecisionType, ActionRequest } from "../types";
import { useStreamContext } from "@/providers/Stream";
interface ThreadActionsViewProps {
interrupt: Interrupt<HITLRequest>;
handleShowSidePanel: (showState: boolean, showDescription: boolean) => void;
showState: boolean;
showDescription: boolean;
}
function ButtonGroup({
handleShowState,
handleShowDescription,
showingState,
showingDescription,
}: {
handleShowState: () => void;
handleShowDescription: () => void;
showingState: boolean;
showingDescription: boolean;
}) {
return (
<div className="flex flex-row items-center justify-center gap-0">
<Button
variant="outline"
className={cn(
"rounded-l-md rounded-r-none border-r-[0px]",
showingState ? "text-black" : "bg-white",
)}
size="sm"
onClick={handleShowState}
>
State
</Button>
<Button
variant="outline"
className={cn(
"rounded-l-none rounded-r-md border-l-[0px]",
showingDescription ? "text-black" : "bg-white",
)}
size="sm"
onClick={handleShowDescription}
>
Description
</Button>
</div>
);
}
function isValidHitlRequest(
interrupt: Interrupt<HITLRequest>,
): interrupt is Interrupt<HITLRequest> & { value: HITLRequest } {
return (
!!interrupt.value &&
Array.isArray(interrupt.value.action_requests) &&
interrupt.value.action_requests.length > 0 &&
Array.isArray(interrupt.value.review_configs) &&
interrupt.value.review_configs.length > 0
);
}
function getDecisionStatus(
decision: Decision | undefined,
): DecisionType | null {
if (!decision) return null;
return decision.type;
}
function getActionTitle(action?: ActionRequest) {
return action?.name ?? "Unknown interrupt";
}
export function ThreadActionsView({
interrupt,
handleShowSidePanel,
showDescription,
showState,
}: ThreadActionsViewProps) {
const stream = useStreamContext();
const [threadId] = useQueryState("threadId");
const [apiUrl] = useQueryState("apiUrl");
const [currentIndex, setCurrentIndex] = useState(0);
const [addressedActions, setAddressedActions] = useState<
Map<number, Decision>
>(new Map());
const [submittingAll, setSubmittingAll] = useState(false);
const hitlValue = interrupt.value;
const actionRequests = useMemo(
() => hitlValue?.action_requests ?? [],
[hitlValue?.action_requests],
);
const reviewConfigs = useMemo(
() => hitlValue?.review_configs ?? [],
[hitlValue?.review_configs],
);
const hasMultipleActions = actionRequests.length > 1;
const currentAction = actionRequests[currentIndex];
const matchingConfig =
reviewConfigs.find(
(config) => config.action_name === currentAction?.name,
) ?? reviewConfigs[currentIndex];
const singleActionInterrupt = useMemo(() => {
if (!currentAction || !matchingConfig) {
return interrupt;
}
return {
...interrupt,
value: {
action_requests: [currentAction],
review_configs: [matchingConfig],
},
};
}, [interrupt, currentAction, matchingConfig]);
const {
approveAllowed,
hasEdited,
hasAddedResponse,
streaming,
supportsMultipleMethods,
streamFinished,
loading,
handleSubmit,
handleResolve,
setSelectedSubmitType,
setHasAddedResponse,
setHasEdited,
humanResponse,
setHumanResponse,
selectedSubmitType,
initialHumanInterruptEditValue,
} = useInterruptedActions({
interrupt: singleActionInterrupt,
});
useEffect(() => {
setCurrentIndex(0);
setAddressedActions(new Map());
}, [interrupt]);
const handleOpenInStudio = () => {
if (!apiUrl) {
toast.error("Error", {
description: "Please set the LangGraph deployment URL in settings.",
duration: 5000,
richColors: true,
closeButton: true,
});
return;
}
const studioUrl = constructOpenInStudioURL(apiUrl, threadId ?? undefined);
window.open(studioUrl, "_blank");
};
const handleApproveAll = useCallback(() => {
if (!hasMultipleActions) return;
try {
const allDecisions: Decision[] = actionRequests.map(() => ({
type: "approve",
}));
stream.submit(
{},
{
command: {
resume: { decisions: allDecisions },
},
},
);
toast("Success", {
description: "All actions approved successfully.",
duration: 5000,
});
} catch (error) {
console.error("Error approving all actions", error);
toast.error("Error", {
description: "Failed to approve all actions.",
richColors: true,
closeButton: true,
duration: 5000,
});
}
}, [actionRequests, hasMultipleActions, stream]);
const handleSubmitAll = useCallback(() => {
if (!hasMultipleActions) return;
if (addressedActions.size !== actionRequests.length) {
toast.error("Error", {
description: `Please address all ${actionRequests.length} actions before submitting.`,
richColors: true,
closeButton: true,
duration: 5000,
});
return;
}
try {
setSubmittingAll(true);
const allDecisions = actionRequests.map((_, index) => {
const decision = addressedActions.get(index);
if (!decision) {
throw new Error(`Missing decision for action ${index + 1}`);
}
return decision;
});
stream.submit(
{},
{
command: {
resume: { decisions: allDecisions },
},
},
);
toast("Success", {
description: "All actions submitted successfully.",
duration: 5000,
});
setAddressedActions(new Map());
} catch (error) {
console.error("Error submitting all actions", error);
toast.error("Error", {
description: "Failed to submit actions.",
richColors: true,
closeButton: true,
duration: 5000,
});
} finally {
setSubmittingAll(false);
}
}, [actionRequests, addressedActions, hasMultipleActions, stream]);
const allAllowApprove = useMemo(() => {
if (!hasMultipleActions) return false;
return actionRequests.every((actionRequest) => {
const matching = reviewConfigs.find(
(config) => config.action_name === actionRequest.name,
);
return matching?.allowed_decisions.includes("approve");
});
}, [actionRequests, reviewConfigs, hasMultipleActions]);
const handleSaveDecision = () => {
const { decision, error } = buildDecisionFromState(
humanResponse,
selectedSubmitType,
);
if (!decision || error) {
toast.error("Error", {
description: error ?? "Unable to determine decision.",
richColors: true,
closeButton: true,
duration: 5000,
});
return;
}
setAddressedActions((prev) => {
const next = new Map(prev);
next.set(currentIndex, decision);
return next;
});
toast("Success", {
description: `Action ${currentIndex + 1} captured.`,
duration: 3000,
});
if (currentIndex < actionRequests.length - 1) {
setCurrentIndex((prev) => Math.min(actionRequests.length - 1, prev + 1));
}
};
const currentTitle = getActionTitle(currentAction);
const actionsDisabled = loading || streaming || submittingAll;
const hasAllDecisions =
hasMultipleActions && addressedActions.size === actionRequests.length;
if (!isValidHitlRequest(interrupt)) {
return (
<div className="flex min-h-full w-full flex-col items-center justify-center rounded-2xl bg-gray-50/50 p-8">
<p className="text-sm text-gray-600">
Unable to render interrupt. The data provided is not in the expected
HITL format.
</p>
</div>
);
}
const interruptValue = singleActionInterrupt.value as HITLRequest;
return (
<div className="flex min-h-full w-full max-w-full flex-col gap-9">
<div className="flex w-full flex-wrap items-center justify-between gap-3">
<div className="flex items-center justify-start gap-3">
<p className="text-2xl tracking-tighter text-pretty">
{hasMultipleActions
? `${currentTitle} (${currentIndex + 1}/${actionRequests.length})`
: currentTitle}
</p>
{threadId && <ThreadIdCopyable threadId={threadId} />}
</div>
<div className="flex flex-row items-center justify-start gap-2">
{apiUrl && (
<Button
size="sm"
variant="outline"
className="flex items-center gap-1 bg-white"
onClick={handleOpenInStudio}
>
Studio
</Button>
)}
<ButtonGroup
handleShowState={() => handleShowSidePanel(true, false)}
handleShowDescription={() => handleShowSidePanel(false, true)}
showingState={showState}
showingDescription={showDescription}
/>
</div>
</div>
<div className="flex w-full flex-row flex-wrap items-center justify-start gap-2">
<Button
variant="outline"
className="border-gray-500 bg-white font-normal text-gray-800"
onClick={handleResolve}
disabled={actionsDisabled}
>
Mark as Resolved
</Button>
{hasMultipleActions && allAllowApprove && (
<Button
variant="outline"
className="border-gray-500 bg-white font-normal text-gray-800"
onClick={handleApproveAll}
disabled={actionsDisabled}
>
Approve All
</Button>
)}
</div>
{hasMultipleActions && (
<div className="flex w-full items-center gap-2">
{actionRequests.map((_, index) => {
const status = getDecisionStatus(addressedActions.get(index));
return (
<button
type="button"
key={index}
onClick={() => setCurrentIndex(index)}
className={cn(
"h-2 flex-1 rounded-full border transition-colors",
"border-gray-300 bg-gray-200",
status === "approve" && "border-emerald-500 bg-emerald-200",
status === "reject" && "border-red-500 bg-red-200",
status === "edit" && "border-amber-500 bg-amber-200",
index === currentIndex &&
"outline-primary outline-2 outline-offset-2",
)}
>
<span className="sr-only">Action {index + 1}</span>
</button>
);
})}
</div>
)}
<InboxItemInput
approveAllowed={approveAllowed}
hasEdited={hasEdited}
hasAddedResponse={hasAddedResponse}
interruptValue={interruptValue}
humanResponse={humanResponse}
initialValues={initialHumanInterruptEditValue.current}
setHumanResponse={setHumanResponse}
supportsMultipleMethods={supportsMultipleMethods}
setSelectedSubmitType={setSelectedSubmitType}
setHasAddedResponse={setHasAddedResponse}
setHasEdited={setHasEdited}
handleSubmit={hasMultipleActions ? handleSaveDecision : handleSubmit}
isLoading={hasMultipleActions ? submittingAll : loading}
selectedSubmitType={selectedSubmitType}
/>
{hasMultipleActions && (
<div className="flex w-full items-center justify-between">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={currentIndex === 0}
onClick={() => setCurrentIndex((prev) => Math.max(0, prev - 1))}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={currentIndex === actionRequests.length - 1}
onClick={() =>
setCurrentIndex((prev) =>
Math.min(actionRequests.length - 1, prev + 1),
)
}
>
Next
</Button>
</div>
<Button
variant="brand"
disabled={!hasAllDecisions || submittingAll}
onClick={handleSubmitAll}
>
{submittingAll
? "Submitting..."
: `Submit all ${actionRequests.length} decisions`}
</Button>
</div>
)}
{!hasMultipleActions && streamFinished && (
<p className="text-base font-medium text-green-600">
Successfully finished Graph invocation.
</p>
)}
</div>
);
}
@@ -1,84 +0,0 @@
import { Copy, CopyCheck } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import React from "react";
import { motion, AnimatePresence } from "framer-motion";
import { TooltipIconButton } from "../../tooltip-icon-button";
export function ThreadIdTooltip({ threadId }: { threadId: string }) {
const firstThreeChars = threadId.slice(0, 3);
const lastThreeChars = threadId.slice(-3);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<p className="rounded-md bg-gray-100 px-1 py-[2px] font-mono text-[10px] leading-[12px] tracking-tighter">
{firstThreeChars}...{lastThreeChars}
</p>
</TooltipTrigger>
<TooltipContent>
<ThreadIdCopyable threadId={threadId} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function ThreadIdCopyable({
threadId,
showUUID = false,
}: {
threadId: string;
showUUID?: boolean;
}) {
const [copied, setCopied] = React.useState(false);
const handleCopy = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
navigator.clipboard.writeText(threadId);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<TooltipIconButton
onClick={(e) => handleCopy(e)}
variant="ghost"
tooltip="Copy thread ID"
className="flex w-fit flex-grow-0 cursor-pointer items-center gap-1 rounded-md border-[1px] border-gray-200 p-1 hover:bg-gray-50/90"
>
<p className="font-mono text-xs">{showUUID ? threadId : "ID"}</p>
<AnimatePresence
mode="wait"
initial={false}
>
{copied ? (
<motion.div
key="check"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
>
<CopyCheck className="h-3 max-h-3 w-3 max-w-3 text-green-500" />
</motion.div>
) : (
<motion.div
key="copy"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
>
<Copy className="h-3 max-h-3 w-3 max-w-3 text-gray-500" />
</motion.div>
)}
</AnimatePresence>
</TooltipIconButton>
);
}
@@ -1,51 +0,0 @@
import { ToolCall } from "@langchain/core/messages/tool";
import { unknownToPrettyDate } from "../utils";
export function ToolCallTable({ toolCall }: { toolCall: ToolCall }) {
return (
<div className="max-w-full min-w-[300px] overflow-hidden rounded-lg border">
<table className="w-full border-collapse">
<thead>
<tr>
<th
className="bg-gray-100 px-2 py-0 text-left text-sm"
colSpan={2}
>
{toolCall.name}
</th>
</tr>
</thead>
<tbody>
{Object.entries(toolCall.args).map(([key, value]) => {
let valueStr = "";
if (["string", "number"].includes(typeof value)) {
valueStr = value.toString();
}
const date = unknownToPrettyDate(value);
if (date) {
valueStr = date;
}
try {
valueStr = valueStr || JSON.stringify(value, null);
} catch (_) {
// failed to stringify, just assign an empty string
valueStr = "";
}
return (
<tr
key={key}
className="border-t"
>
<td className="w-1/3 px-2 py-1 text-xs font-medium">{key}</td>
<td className="px-2 py-1 font-mono text-xs">{valueStr}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
@@ -1,240 +0,0 @@
import { useStreamContext } from "@/providers/Stream";
import { END } from "@langchain/langgraph/web";
import { Interrupt } from "@langchain/langgraph-sdk";
import { toast } from "sonner";
import {
Dispatch,
KeyboardEvent,
MutableRefObject,
SetStateAction,
useEffect,
useRef,
useState,
} from "react";
import { Decision, DecisionWithEdits, HITLRequest, SubmitType } from "../types";
import { buildDecisionFromState, createDefaultHumanResponse } from "../utils";
interface UseInterruptedActionsInput {
interrupt: Interrupt<HITLRequest>;
}
interface UseInterruptedActionsValue {
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | KeyboardEvent,
) => Promise<void>;
handleResolve: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => Promise<void>;
streaming: boolean;
streamFinished: boolean;
loading: boolean;
supportsMultipleMethods: boolean;
hasEdited: boolean;
hasAddedResponse: boolean;
approveAllowed: boolean;
humanResponse: DecisionWithEdits[];
selectedSubmitType: SubmitType | undefined;
setSelectedSubmitType: Dispatch<SetStateAction<SubmitType | undefined>>;
setHumanResponse: Dispatch<SetStateAction<DecisionWithEdits[]>>;
setHasAddedResponse: Dispatch<SetStateAction<boolean>>;
setHasEdited: Dispatch<SetStateAction<boolean>>;
initialHumanInterruptEditValue: MutableRefObject<Record<string, string>>;
}
export default function useInterruptedActions({
interrupt,
}: UseInterruptedActionsInput): UseInterruptedActionsValue {
const thread = useStreamContext();
const [humanResponse, setHumanResponse] = useState<DecisionWithEdits[]>([]);
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState(false);
const [streamFinished, setStreamFinished] = useState(false);
const [selectedSubmitType, setSelectedSubmitType] = useState<SubmitType>();
const [hasEdited, setHasEdited] = useState(false);
const [hasAddedResponse, setHasAddedResponse] = useState(false);
const [approveAllowed, setApproveAllowed] = useState(false);
const initialHumanInterruptEditValue = useRef<Record<string, string>>({});
useEffect(() => {
const hitlValue = interrupt.value as HITLRequest | undefined;
initialHumanInterruptEditValue.current = {};
if (!hitlValue) {
setHumanResponse([]);
setSelectedSubmitType(undefined);
setApproveAllowed(false);
setHasEdited(false);
setHasAddedResponse(false);
return;
}
try {
const { responses, defaultSubmitType, hasApprove } =
createDefaultHumanResponse(hitlValue, initialHumanInterruptEditValue);
setHumanResponse(responses);
setSelectedSubmitType(defaultSubmitType);
setApproveAllowed(hasApprove);
setHasEdited(false);
setHasAddedResponse(false);
} catch (error) {
console.error("Error formatting and setting human response state", error);
setHumanResponse([]);
setSelectedSubmitType(undefined);
setApproveAllowed(false);
}
}, [interrupt]);
const resumeRun = (decisions: Decision[]): boolean => {
try {
thread.submit(
{},
{
command: {
resume: {
decisions,
},
},
},
);
return true;
} catch (error) {
console.error("Error sending human response", error);
return false;
}
};
const handleSubmit = async (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | KeyboardEvent,
) => {
e.preventDefault();
const { decision, error } = buildDecisionFromState(
humanResponse,
selectedSubmitType,
);
if (!decision) {
toast.error("Error", {
description: error ?? "Unsupported response type.",
duration: 5000,
richColors: true,
closeButton: true,
});
return;
}
if (error) {
toast.error("Error", {
description: error,
duration: 5000,
richColors: true,
closeButton: true,
});
return;
}
let errorOccurred = false;
initialHumanInterruptEditValue.current = {};
try {
setLoading(true);
setStreaming(true);
const resumedSuccessfully = resumeRun([decision]);
if (!resumedSuccessfully) {
errorOccurred = true;
return;
}
toast("Success", {
description: "Response submitted successfully.",
duration: 5000,
});
setStreamFinished(true);
} catch (error: any) {
console.error("Error sending human response", error);
errorOccurred = true;
if ("message" in error && error.message.includes("Invalid assistant")) {
toast("Error: Invalid assistant ID", {
description:
"The provided assistant ID was not found in this graph. Please update the assistant ID in the settings and try again.",
richColors: true,
closeButton: true,
duration: 5000,
});
} else {
toast.error("Error", {
description: "Failed to submit response.",
richColors: true,
closeButton: true,
duration: 5000,
});
}
} finally {
setStreaming(false);
setLoading(false);
if (errorOccurred) {
setStreamFinished(false);
}
}
};
const handleResolve = async (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => {
e.preventDefault();
setLoading(true);
initialHumanInterruptEditValue.current = {};
try {
thread.submit(
{},
{
command: {
goto: END,
},
},
);
toast("Success", {
description: "Marked thread as resolved.",
duration: 3000,
});
} catch (error) {
console.error("Error marking thread as resolved", error);
toast.error("Error", {
description: "Failed to mark thread as resolved.",
richColors: true,
closeButton: true,
duration: 3000,
});
} finally {
setLoading(false);
}
};
const supportsMultipleMethods =
humanResponse.filter((response) =>
["edit", "approve", "reject"].includes(response.type),
).length > 1;
return {
handleSubmit,
handleResolve,
humanResponse,
selectedSubmitType,
streaming,
streamFinished,
loading,
supportsMultipleMethods,
hasEdited,
hasAddedResponse,
approveAllowed,
setSelectedSubmitType,
setHumanResponse,
setHasAddedResponse,
setHasEdited,
initialHumanInterruptEditValue,
};
}
@@ -1,104 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { Interrupt } from "@langchain/langgraph-sdk";
import { cn } from "@/lib/utils";
import { useStreamContext } from "@/providers/Stream";
import { HITLRequest } from "./types";
import { StateView } from "./components/state-view";
import { ThreadActionsView } from "./components/thread-actions-view";
interface ThreadViewProps {
interrupt: Interrupt<HITLRequest> | Interrupt<HITLRequest>[];
}
export function ThreadView({ interrupt }: ThreadViewProps) {
const thread = useStreamContext();
const interrupts = useMemo(
() =>
(Array.isArray(interrupt) ? interrupt : [interrupt]).filter(
(item): item is Interrupt<HITLRequest> => !!item,
),
[interrupt],
);
const [activeInterruptIndex, setActiveInterruptIndex] = useState(0);
const [showDescription, setShowDescription] = useState(false);
const [showState, setShowState] = useState(false);
const showSidePanel = showDescription || showState;
useEffect(() => {
setActiveInterruptIndex(0);
}, [interrupts.length]);
const activeInterrupt = interrupts[activeInterruptIndex];
const activeDescription =
activeInterrupt?.value?.action_requests?.[0]?.description ?? "";
const handleShowSidePanel = (
showStateFlag: boolean,
showDescriptionFlag: boolean,
) => {
if (showStateFlag && showDescriptionFlag) {
console.error("Cannot show both state and description");
return;
}
if (showStateFlag) {
setShowDescription(false);
setShowState(true);
} else if (showDescriptionFlag) {
setShowState(false);
setShowDescription(true);
} else {
setShowState(false);
setShowDescription(false);
}
};
if (!activeInterrupt) {
return null;
}
return (
<div className="flex h-full w-full flex-col rounded-2xl bg-gray-50 p-8 lg:flex-row">
{showSidePanel ? (
<StateView
handleShowSidePanel={handleShowSidePanel}
description={activeDescription}
values={thread.values}
view={showState ? "state" : "description"}
/>
) : (
<div className="flex w-full flex-col gap-6">
{interrupts.length > 1 && (
<div className="flex flex-wrap items-center gap-2">
{interrupts.map((it, idx) => {
const title =
it.value?.action_requests?.[0]?.name ??
`Interrupt ${idx + 1}`;
return (
<button
key={it.id ?? idx}
type="button"
onClick={() => setActiveInterruptIndex(idx)}
className={cn(
"rounded-full border px-3 py-1 text-sm transition-colors",
idx === activeInterruptIndex
? "border-primary bg-primary/10 text-primary"
: "hover:border-primary hover:text-primary border-gray-300 bg-white text-gray-600",
)}
>
{title}
</button>
);
})}
</div>
)}
<ThreadActionsView
interrupt={activeInterrupt}
handleShowSidePanel={handleShowSidePanel}
showState={showState}
showDescription={showDescription}
/>
</div>
)}
</div>
);
}
@@ -1,104 +0,0 @@
import { BaseMessage } from "@langchain/core/messages";
import { Interrupt, Thread, ThreadStatus } from "@langchain/langgraph-sdk";
export type DecisionType = "approve" | "edit" | "reject";
export interface Action {
name: string;
args: Record<string, unknown>;
}
export interface ActionRequest {
name: string;
args: Record<string, unknown>;
description?: string;
}
export interface ReviewConfig {
action_name: string;
allowed_decisions: DecisionType[];
args_schema?: Record<string, unknown>;
}
export interface HITLRequest {
action_requests: ActionRequest[];
review_configs: ReviewConfig[];
}
export type Decision =
| { type: "approve" }
| { type: "reject"; message?: string }
| { type: "edit"; edited_action: Action };
export type DecisionWithEdits =
| { type: "approve" }
| { type: "reject"; message?: string }
| {
type: "edit";
edited_action: Action;
acceptAllowed?: boolean;
editsMade?: boolean;
};
export type Email = {
id: string;
thread_id: string;
from_email: string;
to_email: string;
subject: string;
page_content: string;
send_time: string | undefined;
read?: boolean;
status?: "in-queue" | "processing" | "hitl" | "done";
};
export interface ThreadValues {
email: Email;
messages: BaseMessage[];
triage: {
logic: string;
response: string;
};
}
export type ThreadData<
ThreadValues extends Record<string, any> = Record<string, any>,
> = {
thread: Thread<ThreadValues>;
} & (
| {
status: "interrupted";
interrupts: Interrupt<HITLRequest>[] | undefined;
}
| {
status: "idle" | "busy" | "error";
interrupts?: never;
}
);
export type ThreadStatusWithAll = ThreadStatus | "all";
export type SubmitType = DecisionType;
export interface AgentInbox {
/**
* A unique identifier for the inbox.
*/
id: string;
/**
* The ID of the graph.
*/
graphId: string;
/**
* The URL of the deployment. Either a localhost URL, or a deployment URL.
*/
deploymentUrl: string;
/**
* Optional name for the inbox, used in the UI to label the inbox.
*/
name?: string;
/**
* Whether or not the inbox is selected.
*/
selected: boolean;
}
@@ -1,238 +0,0 @@
import { BaseMessage, isBaseMessage } from "@langchain/core/messages";
import { format } from "date-fns";
import { startCase } from "lodash";
import {
Action,
Decision,
DecisionWithEdits,
HITLRequest,
SubmitType,
} from "./types";
export function prettifyText(action: string) {
return startCase(action.replace(/_/g, " "));
}
export function isArrayOfMessages(
value: Record<string, any>[],
): value is BaseMessage[] {
if (
value.every(isBaseMessage) ||
(Array.isArray(value) &&
value.every(
(v) =>
typeof v === "object" &&
"id" in v &&
"type" in v &&
"content" in v &&
"additional_kwargs" in v,
))
) {
return true;
}
return false;
}
export function baseMessageObject(item: unknown): string {
if (isBaseMessage(item)) {
const contentText =
typeof item.content === "string"
? item.content
: JSON.stringify(item.content, null);
let toolCallText = "";
if ("tool_calls" in item) {
toolCallText = JSON.stringify(item.tool_calls, null);
}
if ("type" in item) {
return `${item.type}:${contentText ? ` ${contentText}` : ""}${toolCallText ? ` - Tool calls: ${toolCallText}` : ""}`;
} else if ("getType" in item) {
return `${(item as BaseMessage).getType()}:${contentText ? ` ${contentText}` : ""}${toolCallText ? ` - Tool calls: ${toolCallText}` : ""}`;
}
} else if (
typeof item === "object" &&
item &&
"type" in item &&
"content" in item
) {
const contentText =
typeof item.content === "string"
? item.content
: JSON.stringify(item.content, null);
let toolCallText = "";
if ("tool_calls" in item) {
toolCallText = JSON.stringify(item.tool_calls, null);
}
return `${item.type}:${contentText ? ` ${contentText}` : ""}${toolCallText ? ` - Tool calls: ${toolCallText}` : ""}`;
}
if (typeof item === "object") {
return JSON.stringify(item, null);
} else {
return item as string;
}
}
export function unknownToPrettyDate(input: unknown): string | undefined {
try {
if (
Object.prototype.toString.call(input) === "[object Date]" ||
new Date(input as string)
) {
return format(new Date(input as string), "MM/dd/yyyy hh:mm a");
}
} catch (_) {
// failed to parse date. no-op
}
return undefined;
}
export function createDefaultHumanResponse(
hitlRequest: HITLRequest,
initialHumanInterruptEditValue: React.MutableRefObject<
Record<string, string>
>,
): {
responses: DecisionWithEdits[];
defaultSubmitType: SubmitType | undefined;
hasApprove: boolean;
} {
const responses: DecisionWithEdits[] = [];
const actionRequest = hitlRequest.action_requests?.[0];
const reviewConfig =
hitlRequest.review_configs?.find(
(config) => config.action_name === actionRequest?.name,
) ?? hitlRequest.review_configs?.[0];
if (!actionRequest || !reviewConfig) {
return { responses: [], defaultSubmitType: undefined, hasApprove: false };
}
const allowedDecisions = reviewConfig.allowed_decisions ?? [];
if (allowedDecisions.includes("edit")) {
Object.entries(actionRequest.args).forEach(([key, value]) => {
const stringValue =
typeof value === "string" || typeof value === "number"
? value.toString()
: JSON.stringify(value, null);
initialHumanInterruptEditValue.current = {
...initialHumanInterruptEditValue.current,
[key]: stringValue,
};
});
const editedAction: Action = {
name: actionRequest.name,
args: { ...actionRequest.args },
};
responses.push({
type: "edit",
edited_action: editedAction,
acceptAllowed: allowedDecisions.includes("approve"),
editsMade: false,
});
}
if (allowedDecisions.includes("approve")) {
responses.push({ type: "approve" });
}
if (allowedDecisions.includes("reject")) {
responses.push({ type: "reject", message: "" });
}
// Determine default submit type. Priority: approve > reject > edit
let defaultSubmitType: SubmitType | undefined;
if (allowedDecisions.includes("approve")) {
defaultSubmitType = "approve";
} else if (allowedDecisions.includes("reject")) {
defaultSubmitType = "reject";
} else if (allowedDecisions.includes("edit")) {
defaultSubmitType = "edit";
}
const hasApprove = allowedDecisions.includes("approve");
return { responses, defaultSubmitType, hasApprove };
}
export function buildDecisionFromState(
responses: DecisionWithEdits[],
selectedSubmitType: SubmitType | undefined,
): { decision?: Decision; error?: string } {
if (!responses.length) {
return { error: "Please enter a response." };
}
const selectedDecision = responses.find(
(response) => response.type === selectedSubmitType,
);
if (!selectedDecision) {
return { error: "No response selected." };
}
if (selectedDecision.type === "approve") {
return { decision: { type: "approve" } };
}
if (selectedDecision.type === "reject") {
const message = selectedDecision.message?.trim();
if (!message) {
return { error: "Please provide a rejection reason." };
}
return { decision: { type: "reject", message } };
}
if (selectedDecision.type === "edit") {
if (selectedDecision.acceptAllowed && !selectedDecision.editsMade) {
return { decision: { type: "approve" } };
}
return {
decision: {
type: "edit",
edited_action: selectedDecision.edited_action,
},
};
}
return { error: "Unsupported response type." };
}
export function constructOpenInStudioURL(
deploymentUrl: string,
threadId?: string,
) {
const smithStudioURL = new URL("https://smith.langchain.com/studio/thread");
// trim the trailing slash from deploymentUrl
const trimmedDeploymentUrl = deploymentUrl.replace(/\/$/, "");
if (threadId) {
smithStudioURL.pathname += `/${threadId}`;
}
smithStudioURL.searchParams.append("baseUrl", trimmedDeploymentUrl);
return smithStudioURL.toString();
}
export function haveArgsChanged(
args: unknown,
initialValues: Record<string, string>,
): boolean {
if (typeof args !== "object" || !args) {
return false;
}
const currentValues = args as Record<string, string>;
return Object.entries(currentValues).some(([key, value]) => {
const valueString = ["string", "number"].includes(typeof value)
? value.toString()
: JSON.stringify(value, null);
return initialValues[key] !== valueString;
});
}
@@ -1,189 +0,0 @@
import {
HTMLAttributes,
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
type Setter<T> = (value: T | ((value: T) => T)) => void;
const ArtifactSlotContext = createContext<{
open: [string | null, Setter<string | null>];
mounted: [string | null, Setter<string | null>];
title: [HTMLElement | null, Setter<HTMLElement | null>];
content: [HTMLElement | null, Setter<HTMLElement | null>];
context: [Record<string, unknown>, Setter<Record<string, unknown>>];
}>(null!);
/**
* Headless component that will obtain the title and content of the artifact
* and render them in place of the `ArtifactContent` and `ArtifactTitle` components via
* React Portals.
*/
const ArtifactSlot = (props: {
id: string;
children?: ReactNode;
title?: ReactNode;
}) => {
const context = useContext(ArtifactSlotContext);
const [ctxMounted, ctxSetMounted] = context.mounted;
const [content] = context.content;
const [title] = context.title;
const isMounted = ctxMounted === props.id;
const isEmpty = props.children == null && props.title == null;
useEffect(() => {
if (isEmpty) {
ctxSetMounted((open) => (open === props.id ? null : open));
}
}, [isEmpty, ctxSetMounted, props.id]);
if (!isMounted) return null;
return (
<>
{title != null ? createPortal(<>{props.title}</>, title) : null}
{content != null ? createPortal(<>{props.children}</>, content) : null}
</>
);
};
export function ArtifactContent(props: HTMLAttributes<HTMLDivElement>) {
const context = useContext(ArtifactSlotContext);
const [mounted] = context.mounted;
const ref = useRef<HTMLDivElement>(null);
const [, setStateRef] = context.content;
useLayoutEffect(
() => setStateRef?.(mounted ? ref.current : null),
[setStateRef, mounted],
);
if (!mounted) return null;
return (
<div
{...props}
ref={ref}
/>
);
}
export function ArtifactTitle(props: HTMLAttributes<HTMLDivElement>) {
const context = useContext(ArtifactSlotContext);
const ref = useRef<HTMLDivElement>(null);
const [, setStateRef] = context.title;
useLayoutEffect(() => setStateRef?.(ref.current), [setStateRef]);
return (
<div
{...props}
ref={ref}
/>
);
}
export function ArtifactProvider(props: { children?: ReactNode }) {
const content = useState<HTMLElement | null>(null);
const title = useState<HTMLElement | null>(null);
const open = useState<string | null>(null);
const mounted = useState<string | null>(null);
const context = useState<Record<string, unknown>>({});
return (
<ArtifactSlotContext.Provider
value={{ open, mounted, title, content, context }}
>
{props.children}
</ArtifactSlotContext.Provider>
);
}
/**
* Provides a value to be passed into `meta.artifact` field
* of the `LoadExternalComponent` component, to be consumed by the `useArtifact` hook
* on the generative UI side.
*/
export function useArtifact() {
const id = useId();
const context = useContext(ArtifactSlotContext);
const [ctxOpen, ctxSetOpen] = context.open;
const [ctxContext, ctxSetContext] = context.context;
const [, ctxSetMounted] = context.mounted;
const open = ctxOpen === id;
const setOpen = useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
if (typeof value === "boolean") {
ctxSetOpen(value ? id : null);
} else {
ctxSetOpen((open) => (open === id ? null : id));
}
ctxSetMounted(id);
},
[ctxSetOpen, ctxSetMounted, id],
);
const ArtifactContent = useCallback(
(props: { title?: React.ReactNode; children: React.ReactNode }) => {
return (
<ArtifactSlot
id={id}
title={props.title}
>
{props.children}
</ArtifactSlot>
);
},
[id],
);
return [
ArtifactContent,
{ open, setOpen, context: ctxContext, setContext: ctxSetContext },
] as [
typeof ArtifactContent,
{
open: typeof open;
setOpen: typeof setOpen;
context: typeof ctxContext;
setContext: typeof ctxSetContext;
},
];
}
/**
* General hook for detecting if any artifact is open.
*/
export function useArtifactOpen() {
const context = useContext(ArtifactSlotContext);
const [ctxOpen, setCtxOpen] = context.open;
const open = ctxOpen !== null;
const onClose = useCallback(() => setCtxOpen(null), [setCtxOpen]);
return [open, onClose] as const;
}
/**
* Artifacts may at their discretion provide additional context
* that will be used when creating a new run.
*/
export function useArtifactContext() {
const context = useContext(ArtifactSlotContext);
return context.context;
}
@@ -1,146 +0,0 @@
import { Button } from "@/components/ui/button";
import { useThreads } from "@/providers/Thread";
import { Thread } from "@langchain/langgraph-sdk";
import { useEffect } from "react";
import { getContentString } from "../utils";
import { useQueryState, parseAsBoolean } from "nuqs";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { PanelRightOpen, PanelRightClose } from "lucide-react";
import { useMediaQuery } from "@/hooks/useMediaQuery";
function ThreadList({
threads,
onThreadClick,
}: {
threads: Thread[];
onThreadClick?: (threadId: string) => void;
}) {
const [threadId, setThreadId] = useQueryState("threadId");
return (
<div className="flex h-full w-full flex-col items-start justify-start gap-2 overflow-y-scroll [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
{threads.map((t) => {
let itemText = t.thread_id;
if (
typeof t.values === "object" &&
t.values &&
"messages" in t.values &&
Array.isArray(t.values.messages) &&
t.values.messages?.length > 0
) {
const firstMessage = t.values.messages[0];
itemText = getContentString(firstMessage.content);
}
return (
<div
key={t.thread_id}
className="w-full px-1"
>
<Button
variant="ghost"
className="w-[280px] items-start justify-start text-left font-normal"
onClick={(e) => {
e.preventDefault();
onThreadClick?.(t.thread_id);
if (t.thread_id === threadId) return;
setThreadId(t.thread_id);
}}
>
<p className="truncate text-ellipsis">{itemText}</p>
</Button>
</div>
);
})}
</div>
);
}
function ThreadHistoryLoading() {
return (
<div className="flex h-full w-full flex-col items-start justify-start gap-2 overflow-y-scroll [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
{Array.from({ length: 30 }).map((_, i) => (
<Skeleton
key={`skeleton-${i}`}
className="h-10 w-[280px]"
/>
))}
</div>
);
}
export default function ThreadHistory() {
const isLargeScreen = useMediaQuery("(min-width: 1024px)");
const [chatHistoryOpen, setChatHistoryOpen] = useQueryState(
"chatHistoryOpen",
parseAsBoolean.withDefault(false),
);
const { getThreads, threads, setThreads, threadsLoading, setThreadsLoading } =
useThreads();
useEffect(() => {
if (typeof window === "undefined") return;
setThreadsLoading(true);
getThreads()
.then(setThreads)
.catch(console.error)
.finally(() => setThreadsLoading(false));
}, []);
return (
<>
<div className="shadow-inner-right hidden h-screen w-[300px] shrink-0 flex-col items-start justify-start gap-6 border-r-[1px] border-slate-300 lg:flex">
<div className="flex w-full items-center justify-between px-4 pt-1.5">
<Button
className="hover:bg-gray-100"
variant="ghost"
onClick={() => setChatHistoryOpen((p) => !p)}
>
{chatHistoryOpen ? (
<PanelRightOpen className="size-5" />
) : (
<PanelRightClose className="size-5" />
)}
</Button>
<h1 className="text-xl font-semibold tracking-tight">
Thread History
</h1>
</div>
{threadsLoading ? (
<ThreadHistoryLoading />
) : (
<ThreadList threads={threads} />
)}
</div>
<div className="lg:hidden">
<Sheet
open={!!chatHistoryOpen && !isLargeScreen}
onOpenChange={(open) => {
if (isLargeScreen) return;
setChatHistoryOpen(open);
}}
>
<SheetContent
side="left"
className="flex lg:hidden"
>
<SheetHeader>
<SheetTitle>Thread History</SheetTitle>
</SheetHeader>
<ThreadList
threads={threads}
onThreadClick={() => setChatHistoryOpen((o) => !o)}
/>
</SheetContent>
</Sheet>
</div>
</>
);
}
@@ -1,565 +0,0 @@
import { v4 as uuidv4 } from "uuid";
import { ReactNode, useEffect, useRef } from "react";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
import { useStreamContext } from "@/providers/Stream";
import { useState, FormEvent } from "react";
import { Button } from "../ui/button";
import { Checkpoint, Message } from "@langchain/langgraph-sdk";
import { AssistantMessage, AssistantMessageLoading } from "./messages/ai";
import { HumanMessage } from "./messages/human";
import {
DO_NOT_RENDER_ID_PREFIX,
ensureToolCallsHaveResponses,
} from "@/lib/ensure-tool-responses";
import { LangGraphLogoSVG } from "../icons/langgraph";
import { TooltipIconButton } from "./tooltip-icon-button";
import {
ArrowDown,
LoaderCircle,
PanelRightOpen,
PanelRightClose,
SquarePen,
XIcon,
Plus,
} from "lucide-react";
import { useQueryState, parseAsBoolean } from "nuqs";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
import ThreadHistory from "./history";
import { toast } from "sonner";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
import { GitHubSVG } from "../icons/github";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
import { useFileUpload } from "@/hooks/use-file-upload";
import { ContentBlocksPreview } from "./ContentBlocksPreview";
import {
useArtifactOpen,
ArtifactContent,
ArtifactTitle,
useArtifactContext,
} from "./artifact";
function StickyToBottomContent(props: {
content: ReactNode;
footer?: ReactNode;
className?: string;
contentClassName?: string;
}) {
const context = useStickToBottomContext();
return (
<div
ref={context.scrollRef}
style={{ width: "100%", height: "100%" }}
className={props.className}
>
<div
ref={context.contentRef}
className={props.contentClassName}
>
{props.content}
</div>
{props.footer}
</div>
);
}
function ScrollToBottom(props: { className?: string }) {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
if (isAtBottom) return null;
return (
<Button
variant="outline"
className={props.className}
onClick={() => scrollToBottom()}
>
<ArrowDown className="h-4 w-4" />
<span>Scroll to bottom</span>
</Button>
);
}
function OpenGitHubRepo() {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://github.com/langchain-ai/agent-chat-ui"
target="_blank"
className="flex items-center justify-center"
>
<GitHubSVG
width="24"
height="24"
/>
</a>
</TooltipTrigger>
<TooltipContent side="left">
<p>Open GitHub repo</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function Thread() {
const [artifactContext, setArtifactContext] = useArtifactContext();
const [artifactOpen, closeArtifact] = useArtifactOpen();
const [threadId, _setThreadId] = useQueryState("threadId");
const [chatHistoryOpen, setChatHistoryOpen] = useQueryState(
"chatHistoryOpen",
parseAsBoolean.withDefault(false),
);
const [hideToolCalls, setHideToolCalls] = useQueryState(
"hideToolCalls",
parseAsBoolean.withDefault(false),
);
const [input, setInput] = useState("");
const {
contentBlocks,
setContentBlocks,
handleFileUpload,
dropRef,
removeBlock,
resetBlocks: _resetBlocks,
dragOver,
handlePaste,
} = useFileUpload();
const [firstTokenReceived, setFirstTokenReceived] = useState(false);
const isLargeScreen = useMediaQuery("(min-width: 1024px)");
const stream = useStreamContext();
const messages = stream.messages;
const isLoading = stream.isLoading;
const lastError = useRef<string | undefined>(undefined);
const setThreadId = (id: string | null) => {
_setThreadId(id);
// close artifact and reset artifact context
closeArtifact();
setArtifactContext({});
};
useEffect(() => {
if (!stream.error) {
lastError.current = undefined;
return;
}
try {
const message = (stream.error as any).message;
if (!message || lastError.current === message) {
// Message has already been logged. do not modify ref, return early.
return;
}
// Message is defined, and it has not been logged yet. Save it, and send the error
lastError.current = message;
toast.error("An error occurred. Please try again.", {
description: (
<p>
<strong>Error:</strong> <code>{message}</code>
</p>
),
richColors: true,
closeButton: true,
});
} catch {
// no-op
}
}, [stream.error]);
// TODO: this should be part of the useStream hook
const prevMessageLength = useRef(0);
useEffect(() => {
if (
messages.length !== prevMessageLength.current &&
messages?.length &&
messages[messages.length - 1].type === "ai"
) {
setFirstTokenReceived(true);
}
prevMessageLength.current = messages.length;
}, [messages]);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if ((input.trim().length === 0 && contentBlocks.length === 0) || isLoading)
return;
setFirstTokenReceived(false);
const newHumanMessage: Message = {
id: uuidv4(),
type: "human",
content: [
...(input.trim().length > 0 ? [{ type: "text", text: input }] : []),
...contentBlocks,
] as Message["content"],
};
const toolMessages = ensureToolCallsHaveResponses(stream.messages);
const context =
Object.keys(artifactContext).length > 0 ? artifactContext : undefined;
stream.submit(
{ messages: [...toolMessages, newHumanMessage], context },
{
streamMode: ["values"],
streamSubgraphs: true,
streamResumable: true,
optimisticValues: (prev) => ({
...prev,
context,
messages: [
...(prev.messages ?? []),
...toolMessages,
newHumanMessage,
],
}),
},
);
setInput("");
setContentBlocks([]);
};
const handleRegenerate = (
parentCheckpoint: Checkpoint | null | undefined,
) => {
// Do this so the loading state is correct
prevMessageLength.current = prevMessageLength.current - 1;
setFirstTokenReceived(false);
stream.submit(undefined, {
checkpoint: parentCheckpoint,
streamMode: ["values"],
streamSubgraphs: true,
streamResumable: true,
});
};
const chatStarted = !!threadId || !!messages.length;
const hasNoAIOrToolMessages = !messages.find(
(m) => m.type === "ai" || m.type === "tool",
);
return (
<div className="flex h-screen w-full overflow-hidden">
<div className="relative hidden lg:flex">
<motion.div
className="absolute z-20 h-full overflow-hidden border-r bg-white"
style={{ width: 300 }}
animate={
isLargeScreen
? { x: chatHistoryOpen ? 0 : -300 }
: { x: chatHistoryOpen ? 0 : -300 }
}
initial={{ x: -300 }}
transition={
isLargeScreen
? { type: "spring", stiffness: 300, damping: 30 }
: { duration: 0 }
}
>
<div
className="relative h-full"
style={{ width: 300 }}
>
<ThreadHistory />
</div>
</motion.div>
</div>
<div
className={cn(
"grid w-full grid-cols-[1fr_0fr] transition-all duration-500",
artifactOpen && "grid-cols-[3fr_2fr]",
)}
>
<motion.div
className={cn(
"relative flex min-w-0 flex-1 flex-col overflow-hidden",
!chatStarted && "grid-rows-[1fr]",
)}
layout={isLargeScreen}
animate={{
marginLeft: chatHistoryOpen ? (isLargeScreen ? 300 : 0) : 0,
width: chatHistoryOpen
? isLargeScreen
? "calc(100% - 300px)"
: "100%"
: "100%",
}}
transition={
isLargeScreen
? { type: "spring", stiffness: 300, damping: 30 }
: { duration: 0 }
}
>
{!chatStarted && (
<div className="absolute top-0 left-0 z-10 flex w-full items-center justify-between gap-3 p-2 pl-4">
<div>
{(!chatHistoryOpen || !isLargeScreen) && (
<Button
className="hover:bg-gray-100"
variant="ghost"
onClick={() => setChatHistoryOpen((p) => !p)}
>
{chatHistoryOpen ? (
<PanelRightOpen className="size-5" />
) : (
<PanelRightClose className="size-5" />
)}
</Button>
)}
</div>
<div className="absolute top-2 right-4 flex items-center">
<OpenGitHubRepo />
</div>
</div>
)}
{chatStarted && (
<div className="relative z-10 flex items-center justify-between gap-3 p-2">
<div className="relative flex items-center justify-start gap-2">
<div className="absolute left-0 z-10">
{(!chatHistoryOpen || !isLargeScreen) && (
<Button
className="hover:bg-gray-100"
variant="ghost"
onClick={() => setChatHistoryOpen((p) => !p)}
>
{chatHistoryOpen ? (
<PanelRightOpen className="size-5" />
) : (
<PanelRightClose className="size-5" />
)}
</Button>
)}
</div>
<motion.button
className="flex cursor-pointer items-center gap-2"
onClick={() => setThreadId(null)}
animate={{
marginLeft: !chatHistoryOpen ? 48 : 0,
}}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
>
<LangGraphLogoSVG
width={32}
height={32}
/>
<span className="text-xl font-semibold tracking-tight">
Agent Chat
</span>
</motion.button>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center">
<OpenGitHubRepo />
</div>
<TooltipIconButton
size="lg"
className="p-4"
tooltip="New thread"
variant="ghost"
onClick={() => setThreadId(null)}
>
<SquarePen className="size-5" />
</TooltipIconButton>
</div>
<div className="from-background to-background/0 absolute inset-x-0 top-full h-5 bg-gradient-to-b" />
</div>
)}
<StickToBottom className="relative flex-1 overflow-hidden">
<StickyToBottomContent
className={cn(
"absolute inset-0 overflow-y-scroll px-4 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent",
!chatStarted && "mt-[25vh] flex flex-col items-stretch",
chatStarted && "grid grid-rows-[1fr_auto]",
)}
contentClassName="pt-8 pb-16 max-w-3xl mx-auto flex flex-col gap-4 w-full"
content={
<>
{messages
.filter((m) => !m.id?.startsWith(DO_NOT_RENDER_ID_PREFIX))
.map((message, index) =>
message.type === "human" ? (
<HumanMessage
key={message.id || `${message.type}-${index}`}
message={message}
isLoading={isLoading}
/>
) : (
<AssistantMessage
key={message.id || `${message.type}-${index}`}
message={message}
isLoading={isLoading}
handleRegenerate={handleRegenerate}
/>
),
)}
{/* Special rendering case where there are no AI/tool messages, but there is an interrupt.
We need to render it outside of the messages list, since there are no messages to render */}
{hasNoAIOrToolMessages && !!stream.interrupt && (
<AssistantMessage
key="interrupt-msg"
message={undefined}
isLoading={isLoading}
handleRegenerate={handleRegenerate}
/>
)}
{isLoading && !firstTokenReceived && (
<AssistantMessageLoading />
)}
</>
}
footer={
<div className="sticky bottom-0 flex flex-col items-center gap-8 bg-white">
{!chatStarted && (
<div className="flex items-center gap-3">
<LangGraphLogoSVG className="h-8 flex-shrink-0" />
<h1 className="text-2xl font-semibold tracking-tight">
Agent Chat
</h1>
</div>
)}
<ScrollToBottom className="animate-in fade-in-0 zoom-in-95 absolute bottom-full left-1/2 mb-4 -translate-x-1/2" />
<div
ref={dropRef}
className={cn(
"bg-muted relative z-10 mx-auto mb-8 w-full max-w-3xl rounded-2xl shadow-xs transition-all",
dragOver
? "border-primary border-2 border-dotted"
: "border border-solid",
)}
>
<form
onSubmit={handleSubmit}
className="mx-auto grid max-w-3xl grid-rows-[1fr_auto] gap-2"
>
<ContentBlocksPreview
blocks={contentBlocks}
onRemove={removeBlock}
/>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onPaste={handlePaste}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
!e.shiftKey &&
!e.metaKey &&
!e.nativeEvent.isComposing
) {
e.preventDefault();
const el = e.target as HTMLElement | undefined;
const form = el?.closest("form");
form?.requestSubmit();
}
}}
placeholder="Type your message..."
className="field-sizing-content resize-none border-none bg-transparent p-3.5 pb-0 shadow-none ring-0 outline-none focus:ring-0 focus:outline-none"
/>
<div className="flex items-center gap-6 p-2 pt-4">
<div>
<div className="flex items-center space-x-2">
<Switch
id="render-tool-calls"
checked={hideToolCalls ?? false}
onCheckedChange={setHideToolCalls}
/>
<Label
htmlFor="render-tool-calls"
className="text-sm text-gray-600"
>
Hide Tool Calls
</Label>
</div>
</div>
<Label
htmlFor="file-input"
className="flex cursor-pointer items-center gap-2"
>
<Plus className="size-5 text-gray-600" />
<span className="text-sm text-gray-600">
Upload PDF or Image
</span>
</Label>
<input
id="file-input"
type="file"
onChange={handleFileUpload}
multiple
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf"
className="hidden"
/>
{stream.isLoading ? (
<Button
key="stop"
onClick={() => stream.stop()}
className="ml-auto"
>
<LoaderCircle className="h-4 w-4 animate-spin" />
Cancel
</Button>
) : (
<Button
type="submit"
className="ml-auto shadow-md transition-all"
disabled={
isLoading ||
(!input.trim() && contentBlocks.length === 0)
}
>
Send
</Button>
)}
</div>
</form>
</div>
</div>
}
/>
</StickToBottom>
</motion.div>
<div className="relative flex flex-col border-l">
<div className="absolute inset-0 flex min-w-[30vw] flex-col">
<div className="grid grid-cols-[1fr_auto] border-b p-4">
<ArtifactTitle className="truncate overflow-hidden" />
<button
onClick={closeArtifact}
className="cursor-pointer"
>
<XIcon className="size-5" />
</button>
</div>
<ArtifactContent className="relative flex-grow" />
</div>
</div>
</div>
</div>
);
}
@@ -1,45 +0,0 @@
/* Base markdown styles */
.markdown-content code:not(pre code) {
background-color: rgba(0, 0, 0, 0.05);
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: 0.9em;
}
.markdown-content a {
color: #0070f3;
text-decoration: none;
}
.markdown-content a:hover {
text-decoration: underline;
}
.markdown-content blockquote {
border-left: 4px solid #ddd;
padding-left: 1rem;
color: #666;
}
.markdown-content pre {
overflow-x: auto;
}
.markdown-content table {
border-collapse: collapse;
width: 100%;
}
.markdown-content th,
.markdown-content td {
border: 1px solid #ddd;
padding: 8px;
}
.markdown-content th {
background-color: #f2f2f2;
}
.markdown-content tr:nth-child(even) {
background-color: #f9f9f9;
}
@@ -1,260 +0,0 @@
"use client";
import "./markdown-styles.css";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeKatex from "rehype-katex";
import remarkMath from "remark-math";
import { FC, memo, useState } from "react";
import { CheckIcon, CopyIcon } from "lucide-react";
import { SyntaxHighlighter } from "@/components/thread/syntax-highlighter";
import { TooltipIconButton } from "@/components/thread/tooltip-icon-button";
import { cn } from "@/lib/utils";
import "katex/dist/katex.min.css";
interface CodeHeaderProps {
language?: string;
code: string;
}
const useCopyToClipboard = ({
copiedDuration = 3000,
}: {
copiedDuration?: number;
} = {}) => {
const [isCopied, setIsCopied] = useState<boolean>(false);
const copyToClipboard = (value: string) => {
if (!value) return;
navigator.clipboard.writeText(value).then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), copiedDuration);
});
};
return { isCopied, copyToClipboard };
};
const CodeHeader: FC<CodeHeaderProps> = ({ language, code }) => {
const { isCopied, copyToClipboard } = useCopyToClipboard();
const onCopy = () => {
if (!code || isCopied) return;
copyToClipboard(code);
};
return (
<div className="flex items-center justify-between gap-4 rounded-t-lg bg-zinc-900 px-4 py-2 text-sm font-semibold text-white">
<span className="lowercase [&>span]:text-xs">{language}</span>
<TooltipIconButton
tooltip="Copy"
onClick={onCopy}
>
{!isCopied && <CopyIcon />}
{isCopied && <CheckIcon />}
</TooltipIconButton>
</div>
);
};
const defaultComponents: any = {
h1: ({ className, ...props }: { className?: string }) => (
<h1
className={cn(
"mb-8 scroll-m-20 text-4xl font-extrabold tracking-tight last:mb-0",
className,
)}
{...props}
/>
),
h2: ({ className, ...props }: { className?: string }) => (
<h2
className={cn(
"mt-8 mb-4 scroll-m-20 text-3xl font-semibold tracking-tight first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h3: ({ className, ...props }: { className?: string }) => (
<h3
className={cn(
"mt-6 mb-4 scroll-m-20 text-2xl font-semibold tracking-tight first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h4: ({ className, ...props }: { className?: string }) => (
<h4
className={cn(
"mt-6 mb-4 scroll-m-20 text-xl font-semibold tracking-tight first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h5: ({ className, ...props }: { className?: string }) => (
<h5
className={cn(
"my-4 text-lg font-semibold first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h6: ({ className, ...props }: { className?: string }) => (
<h6
className={cn("my-4 font-semibold first:mt-0 last:mb-0", className)}
{...props}
/>
),
p: ({ className, ...props }: { className?: string }) => (
<p
className={cn("mt-5 mb-5 leading-7 first:mt-0 last:mb-0", className)}
{...props}
/>
),
a: ({ className, ...props }: { className?: string }) => (
<a
className={cn(
"text-primary font-medium underline underline-offset-4",
className,
)}
{...props}
/>
),
blockquote: ({ className, ...props }: { className?: string }) => (
<blockquote
className={cn("border-l-2 pl-6 italic", className)}
{...props}
/>
),
ul: ({ className, ...props }: { className?: string }) => (
<ul
className={cn("my-5 ml-6 list-disc [&>li]:mt-2", className)}
{...props}
/>
),
ol: ({ className, ...props }: { className?: string }) => (
<ol
className={cn("my-5 ml-6 list-decimal [&>li]:mt-2", className)}
{...props}
/>
),
hr: ({ className, ...props }: { className?: string }) => (
<hr
className={cn("my-5 border-b", className)}
{...props}
/>
),
table: ({ className, ...props }: { className?: string }) => (
<table
className={cn(
"my-5 w-full border-separate border-spacing-0 overflow-y-auto",
className,
)}
{...props}
/>
),
th: ({ className, ...props }: { className?: string }) => (
<th
className={cn(
"bg-muted px-4 py-2 text-left font-bold first:rounded-tl-lg last:rounded-tr-lg [&[align=center]]:text-center [&[align=right]]:text-right",
className,
)}
{...props}
/>
),
td: ({ className, ...props }: { className?: string }) => (
<td
className={cn(
"border-b border-l px-4 py-2 text-left last:border-r [&[align=center]]:text-center [&[align=right]]:text-right",
className,
)}
{...props}
/>
),
tr: ({ className, ...props }: { className?: string }) => (
<tr
className={cn(
"m-0 border-b p-0 first:border-t [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg",
className,
)}
{...props}
/>
),
sup: ({ className, ...props }: { className?: string }) => (
<sup
className={cn("[&>a]:text-xs [&>a]:no-underline", className)}
{...props}
/>
),
pre: ({ className, ...props }: { className?: string }) => (
<pre
className={cn(
"max-w-4xl overflow-x-auto rounded-lg bg-black text-white",
className,
)}
{...props}
/>
),
code: ({
className,
children,
...props
}: {
className?: string;
children: React.ReactNode;
}) => {
const match = /language-(\w+)/.exec(className || "");
if (match) {
const language = match[1];
const code = String(children).replace(/\n$/, "");
return (
<>
<CodeHeader
language={language}
code={code}
/>
<SyntaxHighlighter
language={language}
className={className}
>
{code}
</SyntaxHighlighter>
</>
);
}
return (
<code
className={cn("rounded font-semibold", className)}
{...props}
>
{children}
</code>
);
},
};
const MarkdownTextImpl: FC<{ children: string }> = ({ children }) => {
return (
<div className="markdown-content">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={defaultComponents}
>
{children}
</ReactMarkdown>
</div>
);
};
export const MarkdownText = memo(MarkdownTextImpl);
@@ -1,230 +0,0 @@
import { parsePartialJson } from "@langchain/core/output_parsers";
import { useStreamContext } from "@/providers/Stream";
import { AIMessage, Checkpoint, Message } from "@langchain/langgraph-sdk";
import { useStream } from "@langchain/langgraph-sdk/react";
import { getContentString } from "../utils";
import { BranchSwitcher, CommandBar } from "./shared";
import { MarkdownText } from "../markdown-text";
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
import { cn } from "@/lib/utils";
import { ToolCalls, ToolResult } from "./tool-calls";
import { MessageContentComplex } from "@langchain/core/messages";
import { Fragment } from "react/jsx-runtime";
import { isAgentInboxInterruptSchema } from "@/lib/agent-inbox-interrupt";
import { ThreadView } from "../agent-inbox";
import { useQueryState, parseAsBoolean } from "nuqs";
import { GenericInterruptView } from "./generic-interrupt";
import { useArtifact } from "../artifact";
function CustomComponent({
message,
thread,
}: {
message: Message;
thread: ReturnType<typeof useStreamContext>;
}) {
const artifact = useArtifact();
const { values } = useStreamContext();
const customComponents = values.ui?.filter(
(ui) => ui.metadata?.message_id === message.id,
);
if (!customComponents?.length) return null;
return (
<Fragment key={message.id}>
{customComponents.map((customComponent) => (
<LoadExternalComponent
key={customComponent.id}
stream={thread as unknown as ReturnType<typeof useStream>}
message={customComponent}
meta={{ ui: customComponent, artifact }}
/>
))}
</Fragment>
);
}
function parseAnthropicStreamedToolCalls(
content: MessageContentComplex[],
): AIMessage["tool_calls"] {
const toolCallContents = content.filter((c) => c.type === "tool_use" && c.id);
return toolCallContents.map((tc) => {
const toolCall = tc as Record<string, any>;
let json: Record<string, any> = {};
if (toolCall?.input) {
try {
json = parsePartialJson(toolCall.input) ?? {};
} catch {
// Pass
}
}
return {
name: toolCall.name ?? "",
id: toolCall.id ?? "",
args: json,
type: "tool_call",
};
});
}
interface InterruptProps {
interrupt?: unknown;
isLastMessage: boolean;
hasNoAIOrToolMessages: boolean;
}
function Interrupt({
interrupt,
isLastMessage,
hasNoAIOrToolMessages,
}: InterruptProps) {
const fallbackValue = Array.isArray(interrupt)
? (interrupt as Record<string, any>[])
: (((interrupt as { value?: unknown } | undefined)?.value ??
interrupt) as Record<string, any>);
return (
<>
{isAgentInboxInterruptSchema(interrupt) &&
(isLastMessage || hasNoAIOrToolMessages) && (
<ThreadView interrupt={interrupt} />
)}
{interrupt &&
!isAgentInboxInterruptSchema(interrupt) &&
(isLastMessage || hasNoAIOrToolMessages) ? (
<GenericInterruptView interrupt={fallbackValue} />
) : null}
</>
);
}
export function AssistantMessage({
message,
isLoading,
handleRegenerate,
}: {
message: Message | undefined;
isLoading: boolean;
handleRegenerate: (parentCheckpoint: Checkpoint | null | undefined) => void;
}) {
const content = message?.content ?? [];
const contentString = getContentString(content);
const [hideToolCalls] = useQueryState(
"hideToolCalls",
parseAsBoolean.withDefault(false),
);
const thread = useStreamContext();
const isLastMessage =
thread.messages[thread.messages.length - 1].id === message?.id;
const hasNoAIOrToolMessages = !thread.messages.find(
(m) => m.type === "ai" || m.type === "tool",
);
const meta = message ? thread.getMessagesMetadata(message) : undefined;
const threadInterrupt = thread.interrupt;
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
const anthropicStreamedToolCalls = Array.isArray(content)
? parseAnthropicStreamedToolCalls(content)
: undefined;
const hasToolCalls =
message &&
"tool_calls" in message &&
message.tool_calls &&
message.tool_calls.length > 0;
const toolCallsHaveContents =
hasToolCalls &&
message.tool_calls?.some(
(tc) => tc.args && Object.keys(tc.args).length > 0,
);
const hasAnthropicToolCalls = !!anthropicStreamedToolCalls?.length;
const isToolResult = message?.type === "tool";
if (isToolResult && hideToolCalls) {
return null;
}
return (
<div className="group mr-auto flex w-full items-start gap-2">
<div className="flex w-full flex-col gap-2">
{isToolResult ? (
<>
<ToolResult message={message} />
<Interrupt
interrupt={threadInterrupt}
isLastMessage={isLastMessage}
hasNoAIOrToolMessages={hasNoAIOrToolMessages}
/>
</>
) : (
<>
{contentString.length > 0 && (
<div className="py-1">
<MarkdownText>{contentString}</MarkdownText>
</div>
)}
{!hideToolCalls && (
<>
{(hasToolCalls && toolCallsHaveContents && (
<ToolCalls toolCalls={message.tool_calls} />
)) ||
(hasAnthropicToolCalls && (
<ToolCalls toolCalls={anthropicStreamedToolCalls} />
)) ||
(hasToolCalls && (
<ToolCalls toolCalls={message.tool_calls} />
))}
</>
)}
{message && (
<CustomComponent
message={message}
thread={thread}
/>
)}
<Interrupt
interrupt={threadInterrupt}
isLastMessage={isLastMessage}
hasNoAIOrToolMessages={hasNoAIOrToolMessages}
/>
<div
className={cn(
"mr-auto flex items-center gap-2 transition-opacity",
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
)}
>
<BranchSwitcher
branch={meta?.branch}
branchOptions={meta?.branchOptions}
onSelect={(branch) => thread.setBranch(branch)}
isLoading={isLoading}
/>
<CommandBar
content={contentString}
isLoading={isLoading}
isAiMessage={true}
handleRegenerate={() => handleRegenerate(parentCheckpoint)}
/>
</div>
</>
)}
</div>
</div>
);
}
export function AssistantMessageLoading() {
return (
<div className="mr-auto flex items-start gap-2">
<div className="bg-muted flex h-8 items-center gap-1 rounded-2xl px-4 py-2">
<div className="bg-foreground/50 h-1.5 w-1.5 animate-[pulse_1.5s_ease-in-out_infinite] rounded-full"></div>
<div className="bg-foreground/50 h-1.5 w-1.5 animate-[pulse_1.5s_ease-in-out_0.5s_infinite] rounded-full"></div>
<div className="bg-foreground/50 h-1.5 w-1.5 animate-[pulse_1.5s_ease-in-out_1s_infinite] rounded-full"></div>
</div>
</div>
);
}
@@ -1,160 +0,0 @@
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronDown, ChevronUp } from "lucide-react";
function isComplexValue(value: any): boolean {
return Array.isArray(value) || (typeof value === "object" && value !== null);
}
function isUrl(value: any): boolean {
if (typeof value !== "string") return false;
try {
new URL(value);
return value.startsWith("http://") || value.startsWith("https://");
} catch {
return false;
}
}
function renderInterruptStateItem(value: any): React.ReactNode {
if (isComplexValue(value)) {
return (
<code className="rounded bg-gray-50 px-2 py-1 font-mono text-sm">
{JSON.stringify(value, null, 2)}
</code>
);
} else if (isUrl(value)) {
return (
<a
href={value}
target="_blank"
rel="noopener noreferrer"
className="break-all text-blue-600 underline hover:text-blue-800"
>
{value}
</a>
);
} else {
return String(value);
}
}
export function GenericInterruptView({
interrupt,
}: {
interrupt: Record<string, any> | Record<string, any>[];
}) {
const [isExpanded, setIsExpanded] = useState(false);
const contentStr = JSON.stringify(interrupt, null, 2);
const contentLines = contentStr.split("\n");
const shouldTruncate = contentLines.length > 4 || contentStr.length > 500;
// Function to truncate long string values (but preserve URLs)
const truncateValue = (value: any): any => {
if (typeof value === "string" && value.length > 100) {
// Don't truncate URLs so they remain clickable
if (isUrl(value)) {
return value;
}
return value.substring(0, 100) + "...";
}
if (Array.isArray(value) && !isExpanded) {
return value.slice(0, 2).map(truncateValue);
}
if (isComplexValue(value) && !isExpanded) {
const strValue = JSON.stringify(value, null, 2);
if (strValue.length > 100) {
// Return plain text for truncated content instead of a JSON object
return `Truncated ${strValue.length} characters...`;
}
}
return value;
};
// Process entries based on expanded state
const processEntries = () => {
if (Array.isArray(interrupt)) {
return isExpanded ? interrupt : interrupt.slice(0, 5);
} else {
const entries = Object.entries(interrupt);
if (!isExpanded && shouldTruncate) {
// When collapsed, process each value to potentially truncate it
return entries.map(([key, value]) => [key, truncateValue(value)]);
}
return entries;
}
};
const displayEntries = processEntries();
return (
<div className="overflow-hidden rounded-lg border border-gray-200">
<div className="border-b border-gray-200 bg-gray-50 px-4 py-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-medium text-gray-900">Human Interrupt</h3>
</div>
</div>
<motion.div
className="min-w-full bg-gray-100"
initial={false}
animate={{ height: "auto" }}
transition={{ duration: 0.3 }}
>
<div className="p-3">
<AnimatePresence
mode="wait"
initial={false}
>
<motion.div
key={isExpanded ? "expanded" : "collapsed"}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
style={{
maxHeight: isExpanded ? "none" : "500px",
overflow: "auto",
}}
>
<table className="min-w-full divide-y divide-gray-200">
<tbody className="divide-y divide-gray-200">
{displayEntries.map((item, argIdx) => {
const [key, value] = Array.isArray(interrupt)
? [argIdx.toString(), item]
: (item as [string, any]);
return (
<tr key={argIdx}>
<td className="px-4 py-2 text-sm font-medium whitespace-nowrap text-gray-900">
{key}
</td>
<td className="px-4 py-2 text-sm text-gray-500">
{renderInterruptStateItem(value)}
</td>
</tr>
);
})}
</tbody>
</table>
</motion.div>
</AnimatePresence>
</div>
{(shouldTruncate ||
(Array.isArray(interrupt) && interrupt.length > 5)) && (
<motion.button
onClick={() => setIsExpanded(!isExpanded)}
className="flex w-full cursor-pointer items-center justify-center border-t-[1px] border-gray-200 py-2 text-gray-500 transition-all duration-200 ease-in-out hover:bg-gray-50 hover:text-gray-600"
initial={{ scale: 1 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isExpanded ? <ChevronUp /> : <ChevronDown />}
</motion.button>
)}
</motion.div>
</div>
);
}
@@ -1,151 +0,0 @@
import { useStreamContext } from "@/providers/Stream";
import { Message } from "@langchain/langgraph-sdk";
import { useState } from "react";
import { getContentString } from "../utils";
import { cn } from "@/lib/utils";
import { Textarea } from "@/components/ui/textarea";
import { BranchSwitcher, CommandBar } from "./shared";
import { MultimodalPreview } from "@/components/thread/MultimodalPreview";
import { isBase64ContentBlock } from "@/lib/multimodal-utils";
function EditableContent({
value,
setValue,
onSubmit,
}: {
value: string;
setValue: React.Dispatch<React.SetStateAction<string>>;
onSubmit: () => void;
}) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
onSubmit();
}
};
return (
<Textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
className="focus-visible:ring-0"
/>
);
}
export function HumanMessage({
message,
isLoading,
}: {
message: Message;
isLoading: boolean;
}) {
const thread = useStreamContext();
const meta = thread.getMessagesMetadata(message);
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState("");
const contentString = getContentString(message.content);
const handleSubmitEdit = () => {
setIsEditing(false);
const newMessage: Message = { type: "human", content: value };
thread.submit(
{ messages: [newMessage] },
{
checkpoint: parentCheckpoint,
streamMode: ["values"],
streamSubgraphs: true,
streamResumable: true,
optimisticValues: (prev) => {
const values = meta?.firstSeenState?.values;
if (!values) return prev;
return {
...values,
messages: [...(values.messages ?? []), newMessage],
};
},
},
);
};
return (
<div
className={cn(
"group ml-auto flex items-center gap-2",
isEditing && "w-full max-w-xl",
)}
>
<div className={cn("flex flex-col gap-2", isEditing && "w-full")}>
{isEditing ? (
<EditableContent
value={value}
setValue={setValue}
onSubmit={handleSubmitEdit}
/>
) : (
<div className="flex flex-col gap-2">
{/* Render images and files if no text */}
{Array.isArray(message.content) && message.content.length > 0 && (
<div className="flex flex-wrap items-end justify-end gap-2">
{message.content.reduce<React.ReactNode[]>(
(acc, block, idx) => {
if (isBase64ContentBlock(block)) {
acc.push(
<MultimodalPreview
key={idx}
block={block}
size="md"
/>,
);
}
return acc;
},
[],
)}
</div>
)}
{/* Render text if present, otherwise fallback to file/image name */}
{contentString ? (
<p className="bg-muted ml-auto w-fit rounded-3xl px-4 py-2 text-right whitespace-pre-wrap">
{contentString}
</p>
) : null}
</div>
)}
<div
className={cn(
"ml-auto flex items-center gap-2 transition-opacity",
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
isEditing && "opacity-100",
)}
>
<BranchSwitcher
branch={meta?.branch}
branchOptions={meta?.branchOptions}
onSelect={(branch) => thread.setBranch(branch)}
isLoading={isLoading}
/>
<CommandBar
isLoading={isLoading}
content={contentString}
isEditing={isEditing}
setIsEditing={(c) => {
if (c) {
setValue(contentString);
}
setIsEditing(c);
}}
handleSubmitEdit={handleSubmitEdit}
isHumanMessage={true}
/>
</div>
</div>
</div>
);
}
@@ -1,221 +0,0 @@
import {
XIcon,
SendHorizontal,
RefreshCcw,
Pencil,
Copy,
CopyCheck,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { TooltipIconButton } from "../tooltip-icon-button";
import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react";
import { Button } from "@/components/ui/button";
function ContentCopyable({
content,
disabled,
}: {
content: string;
disabled: boolean;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<TooltipIconButton
onClick={(e) => handleCopy(e)}
variant="ghost"
tooltip="Copy content"
disabled={disabled}
>
<AnimatePresence
mode="wait"
initial={false}
>
{copied ? (
<motion.div
key="check"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
>
<CopyCheck className="text-green-500" />
</motion.div>
) : (
<motion.div
key="copy"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
>
<Copy />
</motion.div>
)}
</AnimatePresence>
</TooltipIconButton>
);
}
export function BranchSwitcher({
branch,
branchOptions,
onSelect,
isLoading,
}: {
branch: string | undefined;
branchOptions: string[] | undefined;
onSelect: (branch: string) => void;
isLoading: boolean;
}) {
if (!branchOptions || !branch) return null;
const index = branchOptions.indexOf(branch);
return (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="size-6 p-1"
onClick={() => {
const prevBranch = branchOptions[index - 1];
if (!prevBranch) return;
onSelect(prevBranch);
}}
disabled={isLoading}
>
<ChevronLeft />
</Button>
<span className="text-sm">
{index + 1} / {branchOptions.length}
</span>
<Button
variant="ghost"
size="icon"
className="size-6 p-1"
onClick={() => {
const nextBranch = branchOptions[index + 1];
if (!nextBranch) return;
onSelect(nextBranch);
}}
disabled={isLoading}
>
<ChevronRight />
</Button>
</div>
);
}
export function CommandBar({
content,
isHumanMessage,
isAiMessage,
isEditing,
setIsEditing,
handleSubmitEdit,
handleRegenerate,
isLoading,
}: {
content: string;
isHumanMessage?: boolean;
isAiMessage?: boolean;
isEditing?: boolean;
setIsEditing?: React.Dispatch<React.SetStateAction<boolean>>;
handleSubmitEdit?: () => void;
handleRegenerate?: () => void;
isLoading: boolean;
}) {
if (isHumanMessage && isAiMessage) {
throw new Error(
"Can only set one of isHumanMessage or isAiMessage to true, not both.",
);
}
if (!isHumanMessage && !isAiMessage) {
throw new Error(
"One of isHumanMessage or isAiMessage must be set to true.",
);
}
if (
isHumanMessage &&
(isEditing === undefined ||
setIsEditing === undefined ||
handleSubmitEdit === undefined)
) {
throw new Error(
"If isHumanMessage is true, all of isEditing, setIsEditing, and handleSubmitEdit must be set.",
);
}
const showEdit =
isHumanMessage &&
isEditing !== undefined &&
!!setIsEditing &&
!!handleSubmitEdit;
if (isHumanMessage && isEditing && !!setIsEditing && !!handleSubmitEdit) {
return (
<div className="flex items-center gap-2">
<TooltipIconButton
disabled={isLoading}
tooltip="Cancel edit"
variant="ghost"
onClick={() => {
setIsEditing(false);
}}
>
<XIcon />
</TooltipIconButton>
<TooltipIconButton
disabled={isLoading}
tooltip="Submit"
variant="secondary"
onClick={handleSubmitEdit}
>
<SendHorizontal />
</TooltipIconButton>
</div>
);
}
return (
<div className="flex items-center gap-2">
<ContentCopyable
content={content}
disabled={isLoading}
/>
{isAiMessage && !!handleRegenerate && (
<TooltipIconButton
disabled={isLoading}
tooltip="Refresh"
variant="ghost"
onClick={handleRegenerate}
>
<RefreshCcw />
</TooltipIconButton>
)}
{showEdit && (
<TooltipIconButton
disabled={isLoading}
tooltip="Edit"
variant="ghost"
onClick={() => {
setIsEditing?.(true);
}}
>
<Pencil />
</TooltipIconButton>
)}
</div>
);
}
@@ -1,191 +0,0 @@
import { AIMessage, ToolMessage } from "@langchain/langgraph-sdk";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronDown, ChevronUp } from "lucide-react";
function isComplexValue(value: any): boolean {
return Array.isArray(value) || (typeof value === "object" && value !== null);
}
export function ToolCalls({
toolCalls,
}: {
toolCalls: AIMessage["tool_calls"];
}) {
if (!toolCalls || toolCalls.length === 0) return null;
return (
<div className="mx-auto grid max-w-3xl grid-rows-[1fr_auto] gap-2">
{toolCalls.map((tc, idx) => {
const args = tc.args as Record<string, any>;
const hasArgs = Object.keys(args).length > 0;
return (
<div
key={idx}
className="overflow-hidden rounded-lg border border-gray-200"
>
<div className="border-b border-gray-200 bg-gray-50 px-4 py-2">
<h3 className="font-medium text-gray-900">
{tc.name}
{tc.id && (
<code className="ml-2 rounded bg-gray-100 px-2 py-1 text-sm">
{tc.id}
</code>
)}
</h3>
</div>
{hasArgs ? (
<table className="min-w-full divide-y divide-gray-200">
<tbody className="divide-y divide-gray-200">
{Object.entries(args).map(([key, value], argIdx) => (
<tr key={argIdx}>
<td className="px-4 py-2 text-sm font-medium whitespace-nowrap text-gray-900">
{key}
</td>
<td className="px-4 py-2 text-sm text-gray-500">
{isComplexValue(value) ? (
<code className="rounded bg-gray-50 px-2 py-1 font-mono text-sm break-all">
{JSON.stringify(value, null, 2)}
</code>
) : (
String(value)
)}
</td>
</tr>
))}
</tbody>
</table>
) : (
<code className="block p-3 text-sm">{"{}"}</code>
)}
</div>
);
})}
</div>
);
}
export function ToolResult({ message }: { message: ToolMessage }) {
const [isExpanded, setIsExpanded] = useState(false);
let parsedContent: any;
let isJsonContent = false;
try {
if (typeof message.content === "string") {
parsedContent = JSON.parse(message.content);
isJsonContent = isComplexValue(parsedContent);
}
} catch {
// Content is not JSON, use as is
parsedContent = message.content;
}
const contentStr = isJsonContent
? JSON.stringify(parsedContent, null, 2)
: String(message.content);
const contentLines = contentStr.split("\n");
const shouldTruncate = contentLines.length > 4 || contentStr.length > 500;
const displayedContent =
shouldTruncate && !isExpanded
? contentStr.length > 500
? contentStr.slice(0, 500) + "..."
: contentLines.slice(0, 4).join("\n") + "\n..."
: contentStr;
return (
<div className="mx-auto grid max-w-3xl grid-rows-[1fr_auto] gap-2">
<div className="overflow-hidden rounded-lg border border-gray-200">
<div className="border-b border-gray-200 bg-gray-50 px-4 py-2">
<div className="flex flex-wrap items-center justify-between gap-2">
{message.name ? (
<h3 className="font-medium text-gray-900">
Tool Result:{" "}
<code className="rounded bg-gray-100 px-2 py-1">
{message.name}
</code>
</h3>
) : (
<h3 className="font-medium text-gray-900">Tool Result</h3>
)}
{message.tool_call_id && (
<code className="ml-2 rounded bg-gray-100 px-2 py-1 text-sm">
{message.tool_call_id}
</code>
)}
</div>
</div>
<motion.div
className="min-w-full bg-gray-100"
initial={false}
animate={{ height: "auto" }}
transition={{ duration: 0.3 }}
>
<div className="p-3">
<AnimatePresence
mode="wait"
initial={false}
>
<motion.div
key={isExpanded ? "expanded" : "collapsed"}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
>
{isJsonContent ? (
<table className="min-w-full divide-y divide-gray-200">
<tbody className="divide-y divide-gray-200">
{(Array.isArray(parsedContent)
? isExpanded
? parsedContent
: parsedContent.slice(0, 5)
: Object.entries(parsedContent)
).map((item, argIdx) => {
const [key, value] = Array.isArray(parsedContent)
? [argIdx, item]
: [item[0], item[1]];
return (
<tr key={argIdx}>
<td className="px-4 py-2 text-sm font-medium whitespace-nowrap text-gray-900">
{key}
</td>
<td className="px-4 py-2 text-sm text-gray-500">
{isComplexValue(value) ? (
<code className="rounded bg-gray-50 px-2 py-1 font-mono text-sm break-all">
{JSON.stringify(value, null, 2)}
</code>
) : (
String(value)
)}
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<code className="block text-sm">{displayedContent}</code>
)}
</motion.div>
</AnimatePresence>
</div>
{((shouldTruncate && !isJsonContent) ||
(isJsonContent &&
Array.isArray(parsedContent) &&
parsedContent.length > 5)) && (
<motion.button
onClick={() => setIsExpanded(!isExpanded)}
className="flex w-full cursor-pointer items-center justify-center border-t-[1px] border-gray-200 py-2 text-gray-500 transition-all duration-200 ease-in-out hover:bg-gray-50 hover:text-gray-600"
initial={{ scale: 1 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isExpanded ? <ChevronUp /> : <ChevronDown />}
</motion.button>
)}
</motion.div>
</div>
</div>
);
}
@@ -1,40 +0,0 @@
import { PrismAsyncLight as SyntaxHighlighterPrism } from "react-syntax-highlighter";
import tsx from "react-syntax-highlighter/dist/esm/languages/prism/tsx";
import python from "react-syntax-highlighter/dist/esm/languages/prism/python";
import { coldarkDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { FC } from "react";
// Register languages you want to support
SyntaxHighlighterPrism.registerLanguage("js", tsx);
SyntaxHighlighterPrism.registerLanguage("jsx", tsx);
SyntaxHighlighterPrism.registerLanguage("ts", tsx);
SyntaxHighlighterPrism.registerLanguage("tsx", tsx);
SyntaxHighlighterPrism.registerLanguage("python", python);
interface SyntaxHighlighterProps {
children: string;
language: string;
className?: string;
}
export const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({
children,
language,
className,
}) => {
return (
<SyntaxHighlighterPrism
language={language}
style={coldarkDark}
customStyle={{
margin: 0,
width: "100%",
background: "transparent",
padding: "1.5rem 1rem",
}}
className={className}
>
{children}
</SyntaxHighlighterPrism>
);
};
@@ -1,44 +0,0 @@
"use client";
import { forwardRef } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button, ButtonProps } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type TooltipIconButtonProps = ButtonProps & {
tooltip: string;
side?: "top" | "bottom" | "left" | "right";
};
export const TooltipIconButton = forwardRef<
HTMLButtonElement,
TooltipIconButtonProps
>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
{...rest}
className={cn("size-6 p-1", className)}
ref={ref}
>
{children}
<span className="sr-only">{tooltip}</span>
</Button>
</TooltipTrigger>
<TooltipContent side={side}>{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
});
TooltipIconButton.displayName = "TooltipIconButton";
@@ -1,15 +0,0 @@
import type { Message } from "@langchain/langgraph-sdk";
/**
* Extracts a string summary from a message's content, supporting multimodal (text, image, file, etc.).
* - If text is present, returns the joined text.
* - If not, returns a label for the first non-text modality (e.g., 'Image', 'Other').
* - If unknown, returns 'Multimodal message'.
*/
export function getContentString(content: Message["content"]): string {
if (typeof content === "string") return content;
const texts = content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text);
return texts.join(" ");
}
-51
View File
@@ -1,51 +0,0 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };
-61
View File
@@ -1,61 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline:
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
brand: "bg-[#2F6868] hover:bg-[#2F6868]/90 border-[#2F6868] text-white",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
type ButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
function Button({
className,
variant,
size,
asChild = false,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants, type ButtonProps };
-75
View File
@@ -1,75 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn("flex flex-col gap-1.5 px-6", className)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};
-20
View File
@@ -1,20 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };
-22
View File
@@ -1,22 +0,0 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };
@@ -1,60 +0,0 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Input } from "./input";
import { Button } from "./button";
import { EyeIcon, EyeOffIcon } from "lucide-react";
export const PasswordInput = React.forwardRef<
HTMLInputElement,
React.ComponentProps<"input">
>(({ className, ...props }, ref) => {
const [showPassword, setShowPassword] = React.useState(false);
return (
<div className="relative w-full">
<Input
type={showPassword ? "text" : "password"}
className={cn("hide-password-toggle pr-10", className)}
ref={ref}
{...props}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-0 right-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword((prev) => !prev)}
>
{showPassword ? (
<EyeIcon
className="h-4 w-4"
aria-hidden="true"
/>
) : (
<EyeOffIcon
className="h-4 w-4"
aria-hidden="true"
/>
)}
<span className="sr-only">
{showPassword ? "Hide password" : "Show password"}
</span>
</Button>
{/* hides browsers password toggles */}
<style>{`
.hide-password-toggle::-ms-reveal,
.hide-password-toggle::-ms-clear {
visibility: hidden;
pointer-events: none;
display: none;
}
`}</style>
</div>
);
});
PasswordInput.displayName = "PasswordInput";
@@ -1,26 +0,0 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };
-157
View File
@@ -1,157 +0,0 @@
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return (
<SheetPrimitive.Root
data-slot="sheet"
{...props}
/>
);
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return (
<SheetPrimitive.Trigger
data-slot="sheet-trigger"
{...props}
/>
);
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return (
<SheetPrimitive.Close
data-slot="sheet-close"
{...props}
/>
);
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return (
<SheetPrimitive.Portal
data-slot="sheet-portal"
{...props}
/>
);
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
@@ -1,13 +0,0 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-primary/10 animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };
-27
View File
@@ -1,27 +0,0 @@
import { useTheme } from "next-themes";
import { Toaster as Sonner, ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground font-medium",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground font-medium",
},
}}
{...props}
/>
);
};
export { Toaster };

Some files were not shown because too many files have changed in this diff Show More