Add assistant-ui data agent frontend
This commit is contained in:
+306
-38
@@ -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 +0,0 @@
|
|||||||
productionize
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
node_modules
|
|
||||||
.next
|
|
||||||
.git
|
|
||||||
.env
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
# Ignore artifacts:
|
|
||||||
build
|
|
||||||
coverage
|
|
||||||
|
|
||||||
#
|
|
||||||
pnpm-lock.yaml
|
|
||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Assistant } from "./assistant";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return <Assistant />;
|
||||||
|
}
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
{
|
{
|
||||||
"$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": ""
|
||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils",
|
"utils": "@/lib/utils",
|
||||||
"ui": "@/components/ui",
|
"ui": "@/components/ui",
|
||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide"
|
"iconLibrary": "lucide"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,6 +1,6 @@
|
|||||||
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[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
experimental: {
|
|
||||||
serverActions: {
|
|
||||||
bodySizeLimit: "10mb",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+7322
File diff suppressed because it is too large
Load Diff
+46
-104
@@ -1,106 +1,48 @@
|
|||||||
{
|
{
|
||||||
"name": "claw-code-agent-frontend",
|
"name": "app",
|
||||||
"readme": "./README.md",
|
"version": "0.1.0",
|
||||||
"homepage": "https://github.com/HarnessLab/claw-code-agent",
|
"private": true,
|
||||||
"repository": {
|
"scripts": {
|
||||||
"type": "git",
|
"dev": "next dev --turbopack",
|
||||||
"url": "git+https://github.com/HarnessLab/claw-code-agent.git"
|
"build": "next build",
|
||||||
},
|
"start": "next start",
|
||||||
"private": true,
|
"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",
|
||||||
"version": "0.1.0",
|
"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",
|
||||||
"type": "module",
|
"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",
|
||||||
"scripts": {
|
"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"
|
||||||
"dev": "next dev",
|
},
|
||||||
"build": "next build",
|
"dependencies": {
|
||||||
"start": "next start",
|
"@ai-sdk/openai": "^3.0.53",
|
||||||
"lint": "next lint",
|
"@assistant-ui/react": "latest",
|
||||||
"lint:fix": "next lint --fix",
|
"@assistant-ui/react-ai-sdk": "latest",
|
||||||
"format": "prettier --write .",
|
"@assistant-ui/react-markdown": "latest",
|
||||||
"format:check": "prettier --check ."
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
},
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"dependencies": {
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@langchain/core": "^1.1.44",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@langchain/langgraph": "^1.2.9",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@langchain/langgraph-sdk": "^1.8.10",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"ai": "^6.0.168",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"class-variance-authority": "^0.7.1",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"clsx": "^2.1.1",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"lucide-react": "^1.12.0",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"next": "^16.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"radix-ui": "^1.4.3",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"react": "^19.2.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"react-dom": "^19.2.5",
|
||||||
"clsx": "^2.1.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"date-fns": "^4.1.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"esbuild": "^0.28.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"esbuild-plugin-tailwindcss": "^2.2.0",
|
"tw-shimmer": "^0.4.11",
|
||||||
"framer-motion": "^12.38.0",
|
"zustand": "^5.0.12"
|
||||||
"katex": "^0.16.45",
|
},
|
||||||
"langgraph-nextjs-api-passthrough": "^0.1.4",
|
"devDependencies": {
|
||||||
"lodash": "^4.18.1",
|
"@biomejs/biome": "^2.4.13",
|
||||||
"lucide-react": "^0.476.0",
|
"@tailwindcss/postcss": "^4.2.4",
|
||||||
"next-themes": "^0.4.6",
|
"@types/node": "^25.6.0",
|
||||||
"nuqs": "^2.8.9",
|
"@types/react": "^19.2.14",
|
||||||
"react": "^19.2.5",
|
"@types/react-dom": "^19.2.3",
|
||||||
"react-dom": "^19.2.5",
|
"tailwindcss": "^4.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"typescript": "^6.0.3"
|
||||||
"react-syntax-highlighter": "^15.6.1",
|
}
|
||||||
"recharts": "^2.15.3",
|
|
||||||
"rehype-katex": "^7.0.1",
|
|
||||||
"remark-gfm": "^4.0.1",
|
|
||||||
"remark-math": "^6.0.0",
|
|
||||||
"sonner": "^2.0.7",
|
|
||||||
"tailwind-merge": "^3.5.0",
|
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"use-stick-to-bottom": "^1.1.4",
|
|
||||||
"uuid": "^14.0.0",
|
|
||||||
"zod": "^4.4.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^9.27.0",
|
|
||||||
"@playwright/test": "^1.59.1",
|
|
||||||
"@tailwindcss/postcss": "^4.2.4",
|
|
||||||
"@types/lodash": "^4.17.24",
|
|
||||||
"@types/node": "^22.15.18",
|
|
||||||
"@types/react": "^19.2.14",
|
|
||||||
"@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",
|
|
||||||
"typescript": "~5.8.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"
|
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-7728
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
export default {
|
const config = {
|
||||||
plugins: {
|
plugins: ["@tailwindcss/postcss"],
|
||||||
"@tailwindcss/postcss": {},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|||||||
@@ -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 |
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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">, </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(" ");
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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
Reference in New Issue
Block a user