claude-world-studio 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +30 -0
- package/.mcp.json +51 -0
- package/README.md +224 -0
- package/client/App.tsx +446 -0
- package/client/components/ChatWindow.tsx +790 -0
- package/client/components/FileExplorer.tsx +218 -0
- package/client/components/FilePreviewModal.tsx +179 -0
- package/client/components/PublishDialog.tsx +307 -0
- package/client/components/SettingsPage.tsx +452 -0
- package/client/components/Sidebar.tsx +198 -0
- package/client/components/ToolUseBlock.tsx +140 -0
- package/client/index.html +12 -0
- package/client/index.tsx +10 -0
- package/client/styles/globals.css +48 -0
- package/demo/01-welcome.png +0 -0
- package/demo/02-pipeline-cards.png +0 -0
- package/demo/03-custom-topic-fill.png +0 -0
- package/demo/04-topic-typed.png +0 -0
- package/demo/05-loading-state.png +0 -0
- package/demo/06-tool-calls.png +0 -0
- package/demo/07-history-rich.png +0 -0
- package/demo/09-en-cards.png +0 -0
- package/demo/10-ja-cards.png +0 -0
- package/demo/capture-remaining.mjs +73 -0
- package/demo/capture.mjs +110 -0
- package/demo/demo-walkthrough-2.webm +0 -0
- package/demo/demo-walkthrough.webm +0 -0
- package/package.json +48 -0
- package/postcss.config.js +6 -0
- package/scripts/threads_api.py +536 -0
- package/server/ai-client.ts +356 -0
- package/server/db.ts +299 -0
- package/server/mcp-config.ts +85 -0
- package/server/routes/accounts.ts +88 -0
- package/server/routes/files.ts +175 -0
- package/server/routes/publish.ts +77 -0
- package/server/routes/sessions.ts +59 -0
- package/server/routes/settings.ts +220 -0
- package/server/server.ts +261 -0
- package/server/services/social-publisher.ts +74 -0
- package/server/services/studio-mcp.ts +107 -0
- package/server/session.ts +167 -0
- package/server/types.ts +86 -0
- package/tailwind.config.js +8 -0
- package/tsconfig.json +16 -0
- package/vite.config.ts +19 -0
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect, useMemo } from "react";
|
|
2
|
+
import Markdown from "react-markdown";
|
|
3
|
+
import rehypeSanitize from "rehype-sanitize";
|
|
4
|
+
import { ToolUseBlock } from "./ToolUseBlock";
|
|
5
|
+
import type { Language } from "../App";
|
|
6
|
+
|
|
7
|
+
interface Message {
|
|
8
|
+
id: string;
|
|
9
|
+
role: "user" | "assistant" | "tool_use" | "tool_result" | "result";
|
|
10
|
+
content: string | null;
|
|
11
|
+
timestamp?: string;
|
|
12
|
+
created_at?: string;
|
|
13
|
+
toolName?: string;
|
|
14
|
+
tool_name?: string;
|
|
15
|
+
toolInput?: Record<string, any>;
|
|
16
|
+
tool_input?: string;
|
|
17
|
+
toolId?: string;
|
|
18
|
+
tool_id?: string;
|
|
19
|
+
cost_usd?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ChatWindowProps {
|
|
23
|
+
sessionId: string | null;
|
|
24
|
+
messages: Message[];
|
|
25
|
+
isConnected: boolean;
|
|
26
|
+
isLoading: boolean;
|
|
27
|
+
onSendMessage: (content: string) => void;
|
|
28
|
+
onInterrupt: () => void;
|
|
29
|
+
onShowFiles: () => void;
|
|
30
|
+
onShowPublish: () => void;
|
|
31
|
+
onNewSession: () => void;
|
|
32
|
+
onPreviewFile?: (absolutePath: string) => void;
|
|
33
|
+
workspacePath?: string;
|
|
34
|
+
showFilesActive: boolean;
|
|
35
|
+
language: Language;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// --- i18n strings ---
|
|
39
|
+
|
|
40
|
+
interface QuickChip {
|
|
41
|
+
label: string;
|
|
42
|
+
prompt: string;
|
|
43
|
+
color: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const QUICK_CHIPS: Record<Language, QuickChip[]> = {
|
|
47
|
+
"zh-TW": [
|
|
48
|
+
{ label: "探索趨勢", prompt: "用 get_trending(sources=\"\", geo=\"TW\", count=20) 查詢全部 20 個來源的即時趨勢。注意確認資料的時間線,今天是幾號?只保留最近 48 小時內的資料,列出前 10 名最熱門的話題。", color: "text-emerald-600 bg-emerald-50 border-emerald-200 hover:bg-emerald-100" },
|
|
49
|
+
{ label: "截圖網頁", prompt: "截圖以下網站並描述內容:", color: "text-blue-600 bg-blue-50 border-blue-200 hover:bg-blue-100" },
|
|
50
|
+
{ label: "抓取網頁", prompt: "抓取以下網頁的內容並轉成 Markdown:", color: "text-cyan-600 bg-cyan-50 border-cyan-200 hover:bg-cyan-100" },
|
|
51
|
+
{ label: "內容評分", prompt: "用 get_scoring_guide 評估以下內容的互動分數:", color: "text-orange-600 bg-orange-50 border-orange-200 hover:bg-orange-100" },
|
|
52
|
+
{ label: "NotebookLM", prompt: "用 NotebookLM 研究以下主題,產生音頻摘要:", color: "text-violet-600 bg-violet-50 border-violet-200 hover:bg-violet-100" },
|
|
53
|
+
{ label: "深度研究", prompt: "研究以下主題,搜尋網路並總結關鍵發現:", color: "text-red-600 bg-red-50 border-red-200 hover:bg-red-100" },
|
|
54
|
+
],
|
|
55
|
+
"en": [
|
|
56
|
+
{ label: "Trends", prompt: "Use get_trending(sources=\"\", geo=\"US\", count=20) to query ALL 20 sources for real-time trends. Check timestamps — what's today's date? Keep only items from the last 48 hours. Show the top 10 hottest topics.", color: "text-emerald-600 bg-emerald-50 border-emerald-200 hover:bg-emerald-100" },
|
|
57
|
+
{ label: "Screenshot", prompt: "Take a screenshot of this website and describe it: ", color: "text-blue-600 bg-blue-50 border-blue-200 hover:bg-blue-100" },
|
|
58
|
+
{ label: "Scrape Page", prompt: "Scrape this webpage and convert to markdown: ", color: "text-cyan-600 bg-cyan-50 border-cyan-200 hover:bg-cyan-100" },
|
|
59
|
+
{ label: "Score Content", prompt: "Use get_scoring_guide to score this content: ", color: "text-orange-600 bg-orange-50 border-orange-200 hover:bg-orange-100" },
|
|
60
|
+
{ label: "NotebookLM", prompt: "Use NotebookLM to research this topic and generate an audio summary: ", color: "text-violet-600 bg-violet-50 border-violet-200 hover:bg-violet-100" },
|
|
61
|
+
{ label: "Research", prompt: "Research this topic, search the web and summarize key findings: ", color: "text-red-600 bg-red-50 border-red-200 hover:bg-red-100" },
|
|
62
|
+
],
|
|
63
|
+
"ja": [
|
|
64
|
+
{ label: "トレンド", prompt: "get_trending(sources=\"\", geo=\"JP\", count=20) で全20ソースのリアルタイムトレンドを取得。タイムスタンプを確認して48時間以内のデータのみ表示。上位10件を表示。", color: "text-emerald-600 bg-emerald-50 border-emerald-200 hover:bg-emerald-100" },
|
|
65
|
+
{ label: "スクショ", prompt: "このWebサイトのスクリーンショットを撮って説明してください:", color: "text-blue-600 bg-blue-50 border-blue-200 hover:bg-blue-100" },
|
|
66
|
+
{ label: "ページ取得", prompt: "このWebページの内容を取得してMarkdownに変換してください:", color: "text-cyan-600 bg-cyan-50 border-cyan-200 hover:bg-cyan-100" },
|
|
67
|
+
{ label: "スコア評価", prompt: "get_scoring_guideで以下のコンテンツを評価してください:", color: "text-orange-600 bg-orange-50 border-orange-200 hover:bg-orange-100" },
|
|
68
|
+
{ label: "NotebookLM", prompt: "NotebookLMでこのトピックを調査し、音声サマリーを生成してください:", color: "text-violet-600 bg-violet-50 border-violet-200 hover:bg-violet-100" },
|
|
69
|
+
{ label: "調査", prompt: "このトピックを調査し、Webを検索して要点をまとめてください:", color: "text-red-600 bg-red-50 border-red-200 hover:bg-red-100" },
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// --- Pipeline action cards for the empty chat state ---
|
|
74
|
+
|
|
75
|
+
interface PipelineAction {
|
|
76
|
+
icon: string;
|
|
77
|
+
label: string;
|
|
78
|
+
description: string;
|
|
79
|
+
mode: "send" | "fill";
|
|
80
|
+
prompt: string;
|
|
81
|
+
hint?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const PIPELINE_ACTIONS: Record<Language, PipelineAction[]> = {
|
|
85
|
+
"zh-TW": [
|
|
86
|
+
{
|
|
87
|
+
icon: "🔥",
|
|
88
|
+
label: "自動發文 (Freestyle)",
|
|
89
|
+
description: "一鍵完成:趨勢探索 → 深度研究 → 評分 → 發文",
|
|
90
|
+
mode: "send",
|
|
91
|
+
prompt: "執行完整內容產線(趨勢→讀原文→驗時間→創作→評分→發布):\n1. get_trending(sources=\"\", geo=\"TW\", count=20) 查詢全部 20 個來源\n2. 確認時間線(今天幾號?),過濾 48h 以上舊資料\n3. 選出最有潛力且最即時的 1 個話題\n4. 【必做】browser_markdown 讀原文(至少 2-3 個一手來源),不可只看標題\n5. 【必做】驗證每個事實的時間戳,套用時間詞對照表\n6. get_content_brief(topic) 取得寫作策略\n7. 撰寫高互動 Threads 貼文(繁中,500 字內),按 Meta 5 維專利逐項檢查:\n - Hook 有數字或反差?(EdgeRank)\n - CTA 人人都能回答?(Dear Algo)\n - 有正反兩面/轉折?(72hr window)\n - 夠短且即時?(Andromeda)\n - 手機可掃描?(Multi-modal)\n8. get_scoring_guide 自我評分(Overall ≥ 70, Conversation Durability ≥ 55)\n9. get_review_checklist 最終品質檢查,移除 AI 腔(在當今/隨著/值得注意)\n10. 問我是否要用 publish_to_threads 發布(--poll 做投票, --link-comment 放連結)\n\n開始!",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
icon: "🎯",
|
|
95
|
+
label: "指定主題發文",
|
|
96
|
+
description: "輸入你的主題 → 深度研究 → 評分 → 發文",
|
|
97
|
+
mode: "fill",
|
|
98
|
+
prompt: "研究以下主題,深度分析後產出高評分貼文:",
|
|
99
|
+
hint: "輸入主題,例如:Claude Code 新功能、AI Agent 趨勢...",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
icon: "🎬",
|
|
103
|
+
label: "指定主題 + 多媒體",
|
|
104
|
+
description: "主題 → 研究 → NotebookLM 簡報/影片/Podcast → 發文",
|
|
105
|
+
mode: "fill",
|
|
106
|
+
prompt: "研究以下主題,產出 NotebookLM 簡報/影片/Podcast + 高評分貼文:",
|
|
107
|
+
hint: "輸入主題,例如:AI 程式碼助手比較...",
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
"en": [
|
|
111
|
+
{
|
|
112
|
+
icon: "🔥",
|
|
113
|
+
label: "Auto Post (Freestyle)",
|
|
114
|
+
description: "One click: Trends → Research → Score → Publish",
|
|
115
|
+
mode: "send",
|
|
116
|
+
prompt: "Run the full content pipeline (Trends→Read Source→Verify Timeline→Create→Score→Publish):\n1. get_trending(sources=\"\", geo=\"US\", count=20) — ALL 20 sources\n2. Check timestamps (today's date?), discard >48h old data\n3. Pick the most promising & freshest topic\n4. [MANDATORY] browser_markdown — read 2-3 original sources (never write from titles alone)\n5. [MANDATORY] Verify every fact's timestamp, use correct time words\n6. get_content_brief(topic) for writing strategy\n7. Write a high-engagement Threads post (under 500 chars), check Meta's 5 patent dimensions:\n - Hook has number/contrast? (EdgeRank)\n - CTA anyone can answer? (Dear Algo)\n - Has both sides/contrast? (72hr window)\n - Short & timely? (Andromeda)\n - Mobile-scannable? (Multi-modal)\n8. get_scoring_guide — self-score (Overall ≥70, Conversation Durability ≥55)\n9. get_review_checklist — final quality check, remove AI filler\n10. Ask me whether to publish_to_threads (use --poll for polls, --link-comment for URLs)\n\nGo!",
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
icon: "🎯",
|
|
120
|
+
label: "Custom Topic Post",
|
|
121
|
+
description: "Enter your topic → Research → Score → Publish",
|
|
122
|
+
mode: "fill",
|
|
123
|
+
prompt: "Research this topic, do a deep analysis and create a high-scoring post: ",
|
|
124
|
+
hint: "Enter a topic, e.g. Claude Code new features, AI Agent trends...",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
icon: "🎬",
|
|
128
|
+
label: "Custom Topic + Media",
|
|
129
|
+
description: "Topic → Research → NotebookLM slides/video/podcast → Publish",
|
|
130
|
+
mode: "fill",
|
|
131
|
+
prompt: "Research this topic, create NotebookLM slides/video/podcast + a high-scoring post: ",
|
|
132
|
+
hint: "Enter a topic, e.g. AI coding assistants comparison...",
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
"ja": [
|
|
136
|
+
{
|
|
137
|
+
icon: "🔥",
|
|
138
|
+
label: "自動投稿 (Freestyle)",
|
|
139
|
+
description: "ワンクリック:トレンド → 調査 → スコア → 投稿",
|
|
140
|
+
mode: "send",
|
|
141
|
+
prompt: "フルコンテンツパイプライン(トレンド→原文読解→タイムライン検証→作成→採点→公開):\n1. get_trending(sources=\"\", geo=\"JP\", count=20) で全20ソース取得\n2. タイムスタンプ確認(今日の日付は?)、48h超は除外\n3. 最も有望で最新のトピックを1つ選択\n4. 【必須】browser_markdown で原文を2-3件読む(タイトルだけで書かない)\n5. 【必須】各事実のタイムスタンプを検証、時間表現を確認\n6. get_content_brief(topic) で執筆戦略取得\n7. 高エンゲージメントThreads投稿を作成(500文字以内)、Meta特許5次元チェック:\n - Hookに数字/対比?(EdgeRank)\n - CTAは誰でも回答可能?(Dear Algo)\n - 両面/転換点がある?(72hr window)\n - 短く即時的?(Andromeda)\n - モバイルでスキャン可能?(Multi-modal)\n8. get_scoring_guide で自己採点(Overall≥70, 会話持続性≥55)\n9. get_review_checklist で最終品質チェック、AI表現を除去\n10. publish_to_threads で公開するか確認(--pollで投票、--link-commentでURL)\n\n開始!",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
icon: "🎯",
|
|
145
|
+
label: "トピック指定投稿",
|
|
146
|
+
description: "トピック入力 → 調査 → スコア → 投稿",
|
|
147
|
+
mode: "fill",
|
|
148
|
+
prompt: "以下のトピックを調査し、深く分析して高スコアの投稿を作成してください:",
|
|
149
|
+
hint: "トピックを入力、例:Claude Codeの新機能、AIエージェントのトレンド...",
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
icon: "🎬",
|
|
153
|
+
label: "トピック + マルチメディア",
|
|
154
|
+
description: "トピック → 調査 → NotebookLM スライド/動画/Podcast → 投稿",
|
|
155
|
+
mode: "fill",
|
|
156
|
+
prompt: "以下のトピックを調査し、NotebookLM スライド/動画/Podcast + 高スコア投稿を作成:",
|
|
157
|
+
hint: "トピックを入力、例:AIコーディングアシスタント比較...",
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const UI_TEXT: Record<Language, {
|
|
163
|
+
welcomeTitle: string;
|
|
164
|
+
welcomeSubtitle: string;
|
|
165
|
+
welcomeCta: string;
|
|
166
|
+
welcomeHint: string;
|
|
167
|
+
emptyTitle: string;
|
|
168
|
+
emptySubtitle: string;
|
|
169
|
+
advancedTools: string;
|
|
170
|
+
placeholder: string;
|
|
171
|
+
placeholderOffline: string;
|
|
172
|
+
sendBtn: string;
|
|
173
|
+
stopBtn: string;
|
|
174
|
+
filesBtn: string;
|
|
175
|
+
publishBtn: string;
|
|
176
|
+
shiftEnter: string;
|
|
177
|
+
poweredBy: string;
|
|
178
|
+
thinkingLabel: string;
|
|
179
|
+
startConversation: string;
|
|
180
|
+
steps: string[];
|
|
181
|
+
stepsDesc: string[];
|
|
182
|
+
}> = {
|
|
183
|
+
"zh-TW": {
|
|
184
|
+
welcomeTitle: "Claude World Studio",
|
|
185
|
+
welcomeSubtitle: "AI 驅動的內容產線:從趨勢發現到社群發文",
|
|
186
|
+
welcomeCta: "開始新 Session",
|
|
187
|
+
welcomeHint: "或從左側選擇現有的 Session",
|
|
188
|
+
emptyTitle: "你想做什麼?",
|
|
189
|
+
emptySubtitle: "選擇快速操作,或在下方輸入你的需求",
|
|
190
|
+
advancedTools: "進階工具",
|
|
191
|
+
placeholder: "請輸入指令:發現趨勢、研究主題、撰寫內容...",
|
|
192
|
+
placeholderOffline: "正在連線至伺服器...",
|
|
193
|
+
sendBtn: "送出",
|
|
194
|
+
stopBtn: "停止",
|
|
195
|
+
filesBtn: "檔案",
|
|
196
|
+
publishBtn: "發文",
|
|
197
|
+
shiftEnter: "Shift+Enter 換行",
|
|
198
|
+
poweredBy: "由 Claude Agent SDK + MCP 驅動",
|
|
199
|
+
thinkingLabel: "Claude 正在思考...",
|
|
200
|
+
startConversation: "開始對話",
|
|
201
|
+
steps: ["發現", "研究", "發佈"],
|
|
202
|
+
stepsDesc: [
|
|
203
|
+
"從 Google Trends、Hacker News、GitHub 等 15+ 來源發現熱門話題",
|
|
204
|
+
"透過網頁擷取、NotebookLM、AI 分析深入研究主題",
|
|
205
|
+
"評分優化後一鍵發佈到 Threads 和 Instagram",
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
"en": {
|
|
209
|
+
welcomeTitle: "Claude World Studio",
|
|
210
|
+
welcomeSubtitle: "AI-powered content pipeline: from trend discovery to social publishing",
|
|
211
|
+
welcomeCta: "Start a New Session",
|
|
212
|
+
welcomeHint: "Or select an existing session from the sidebar",
|
|
213
|
+
emptyTitle: "What would you like to do?",
|
|
214
|
+
emptySubtitle: "Choose a quick action or type your own request below",
|
|
215
|
+
advancedTools: "Advanced Tools",
|
|
216
|
+
placeholder: "Ask Claude to discover trends, research topics, create content...",
|
|
217
|
+
placeholderOffline: "Connecting to server...",
|
|
218
|
+
sendBtn: "Send",
|
|
219
|
+
stopBtn: "Stop",
|
|
220
|
+
filesBtn: "Files",
|
|
221
|
+
publishBtn: "Publish",
|
|
222
|
+
shiftEnter: "Shift+Enter for newline",
|
|
223
|
+
poweredBy: "Powered by Claude Agent SDK + MCP",
|
|
224
|
+
thinkingLabel: "Claude is working...",
|
|
225
|
+
startConversation: "Start a conversation",
|
|
226
|
+
steps: ["Discover", "Research", "Publish"],
|
|
227
|
+
stepsDesc: [
|
|
228
|
+
"Find trending topics from Google Trends, Hacker News, GitHub, Reddit, and 11 more sources",
|
|
229
|
+
"Deep dive with web scraping, NotebookLM, and AI-powered analysis",
|
|
230
|
+
"Score-optimized content published to Threads and Instagram with one click",
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
"ja": {
|
|
234
|
+
welcomeTitle: "Claude World Studio",
|
|
235
|
+
welcomeSubtitle: "AI搭載コンテンツパイプライン:トレンド発見からSNS投稿まで",
|
|
236
|
+
welcomeCta: "新しいセッションを開始",
|
|
237
|
+
welcomeHint: "またはサイドバーから既存のセッションを選択",
|
|
238
|
+
emptyTitle: "何をしますか?",
|
|
239
|
+
emptySubtitle: "クイックアクションを選択するか、下にリクエストを入力してください",
|
|
240
|
+
advancedTools: "高度なツール",
|
|
241
|
+
placeholder: "トレンド発見、トピック調査、コンテンツ作成を依頼...",
|
|
242
|
+
placeholderOffline: "サーバーに接続中...",
|
|
243
|
+
sendBtn: "送信",
|
|
244
|
+
stopBtn: "停止",
|
|
245
|
+
filesBtn: "ファイル",
|
|
246
|
+
publishBtn: "投稿",
|
|
247
|
+
shiftEnter: "Shift+Enterで改行",
|
|
248
|
+
poweredBy: "Claude Agent SDK + MCP 搭載",
|
|
249
|
+
thinkingLabel: "Claude が作業中...",
|
|
250
|
+
startConversation: "会話を始めましょう",
|
|
251
|
+
steps: ["発見", "調査", "公開"],
|
|
252
|
+
stepsDesc: [
|
|
253
|
+
"Google Trends、Hacker News、GitHub、Redditなど15+ソースからトレンドを発見",
|
|
254
|
+
"Webスクレイピング、NotebookLM、AI分析で深掘り調査",
|
|
255
|
+
"スコア最適化後ワンクリックでThreadsとInstagramに投稿",
|
|
256
|
+
],
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const STEP_TOOLS = ["trend-pulse", "cf-browser + notebooklm", "publish_to_threads"];
|
|
261
|
+
const STEP_COLORS = ["bg-emerald-500", "bg-blue-500", "bg-pink-500"];
|
|
262
|
+
|
|
263
|
+
// Detect if text inside backticks looks like a workspace file path with a previewable extension
|
|
264
|
+
const PREVIEW_EXT_RE = /\.(png|jpg|jpeg|gif|webp|svg|pdf|mp3|wav|m4a|mp4|webm|md|txt|json|html|css|py|ts|tsx|js|jsx)$/i;
|
|
265
|
+
function isPreviewablePath(text: string, workspacePath?: string): boolean {
|
|
266
|
+
if (!text.startsWith("/") || !PREVIEW_EXT_RE.test(text) || text.includes(" ")) return false;
|
|
267
|
+
// Only treat as previewable if path is within the workspace
|
|
268
|
+
if (workspacePath) {
|
|
269
|
+
const wsBase = workspacePath.replace(/\/$/, "");
|
|
270
|
+
return text.startsWith(wsBase + "/");
|
|
271
|
+
}
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Hoisted component: renders inline code with clickable file path support
|
|
276
|
+
function InlineCode({
|
|
277
|
+
children,
|
|
278
|
+
className,
|
|
279
|
+
onPreviewFile,
|
|
280
|
+
workspacePath,
|
|
281
|
+
}: {
|
|
282
|
+
children: React.ReactNode;
|
|
283
|
+
className?: string;
|
|
284
|
+
onPreviewFile: (path: string) => void;
|
|
285
|
+
workspacePath?: string;
|
|
286
|
+
}) {
|
|
287
|
+
// Only handle inline code (className present = fenced code block language)
|
|
288
|
+
if (className) {
|
|
289
|
+
return <code className={className}>{children}</code>;
|
|
290
|
+
}
|
|
291
|
+
const text = String(children);
|
|
292
|
+
if (isPreviewablePath(text, workspacePath)) {
|
|
293
|
+
return (
|
|
294
|
+
<button
|
|
295
|
+
type="button"
|
|
296
|
+
className="text-blue-600 bg-blue-50 px-1 py-0.5 rounded text-xs cursor-pointer hover:bg-blue-100 hover:underline transition-colors font-mono inline"
|
|
297
|
+
onClick={() => onPreviewFile(text)}
|
|
298
|
+
title="Click to preview"
|
|
299
|
+
aria-label={`Preview file ${text.split("/").pop()}`}
|
|
300
|
+
>
|
|
301
|
+
{children}
|
|
302
|
+
</button>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
return <code>{children}</code>;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function MessageBubble({
|
|
309
|
+
message,
|
|
310
|
+
onPreviewFile,
|
|
311
|
+
workspacePath,
|
|
312
|
+
}: {
|
|
313
|
+
message: Message;
|
|
314
|
+
onPreviewFile?: (path: string) => void;
|
|
315
|
+
workspacePath?: string;
|
|
316
|
+
}) {
|
|
317
|
+
const isUser = message.role === "user";
|
|
318
|
+
const content = message.content || "";
|
|
319
|
+
|
|
320
|
+
const markdownComponents = useMemo(() => {
|
|
321
|
+
if (!onPreviewFile) return undefined;
|
|
322
|
+
return {
|
|
323
|
+
code: (props: any) => (
|
|
324
|
+
<InlineCode
|
|
325
|
+
{...props}
|
|
326
|
+
onPreviewFile={onPreviewFile}
|
|
327
|
+
workspacePath={workspacePath}
|
|
328
|
+
/>
|
|
329
|
+
),
|
|
330
|
+
};
|
|
331
|
+
}, [onPreviewFile, workspacePath]);
|
|
332
|
+
|
|
333
|
+
return (
|
|
334
|
+
<div className={`flex ${isUser ? "justify-end" : "justify-start"}`}>
|
|
335
|
+
<div
|
|
336
|
+
className={`max-w-[80%] rounded-lg px-4 py-2 ${
|
|
337
|
+
isUser
|
|
338
|
+
? "bg-blue-600 text-white"
|
|
339
|
+
: "bg-gray-100 text-gray-900"
|
|
340
|
+
}`}
|
|
341
|
+
>
|
|
342
|
+
{isUser ? (
|
|
343
|
+
<p className="whitespace-pre-wrap break-words">{content}</p>
|
|
344
|
+
) : (
|
|
345
|
+
<div className="prose prose-sm max-w-none prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-pre:my-2 prose-code:text-violet-700 prose-code:bg-violet-50 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-pre:bg-gray-800 prose-pre:text-gray-100 prose-pre:rounded-lg prose-a:text-blue-600">
|
|
346
|
+
<Markdown rehypePlugins={[rehypeSanitize]} components={markdownComponents}>
|
|
347
|
+
{content}
|
|
348
|
+
</Markdown>
|
|
349
|
+
</div>
|
|
350
|
+
)}
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function ResultBlock({ message }: { message: Message }) {
|
|
357
|
+
let data: { success?: boolean; cost?: number; duration?: number } = {};
|
|
358
|
+
try {
|
|
359
|
+
data = JSON.parse(message.content || "{}");
|
|
360
|
+
} catch {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return (
|
|
365
|
+
<div className="flex justify-center my-2">
|
|
366
|
+
<div className="text-xs text-gray-400 flex items-center gap-3 bg-gray-50 px-3 py-1 rounded-full">
|
|
367
|
+
<span className={data.success ? "text-green-500" : "text-red-500"}>
|
|
368
|
+
{data.success ? "Completed" : "Failed"}
|
|
369
|
+
</span>
|
|
370
|
+
{data.cost != null && <span>${data.cost.toFixed(4)}</span>}
|
|
371
|
+
{data.duration != null && <span>{(data.duration / 1000).toFixed(1)}s</span>}
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function ToolResultBlock({ message }: { message: Message }) {
|
|
378
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
379
|
+
const content = message.content || "";
|
|
380
|
+
const preview = content.length > 120 ? content.slice(0, 120) + "..." : content;
|
|
381
|
+
|
|
382
|
+
return (
|
|
383
|
+
<div className="my-1">
|
|
384
|
+
<div
|
|
385
|
+
role="button"
|
|
386
|
+
tabIndex={0}
|
|
387
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
388
|
+
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setIsExpanded(!isExpanded); } }}
|
|
389
|
+
className="text-[11px] text-gray-400 hover:text-gray-500 cursor-pointer flex items-center gap-1 px-1"
|
|
390
|
+
>
|
|
391
|
+
<span>{isExpanded ? "▼" : "▶"}</span>
|
|
392
|
+
<span className="font-medium">result</span>
|
|
393
|
+
{!isExpanded && <span className="truncate max-w-xs">{preview}</span>}
|
|
394
|
+
</div>
|
|
395
|
+
{isExpanded && (
|
|
396
|
+
<pre className="text-[11px] text-gray-500 bg-gray-50 rounded p-2 mt-1 mx-1 max-h-48 overflow-auto whitespace-pre-wrap break-words">
|
|
397
|
+
{content}
|
|
398
|
+
</pre>
|
|
399
|
+
)}
|
|
400
|
+
</div>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function TypingIndicator({ language }: { language: Language }) {
|
|
405
|
+
const t = UI_TEXT[language];
|
|
406
|
+
return (
|
|
407
|
+
<div className="flex items-center gap-1.5 py-2 px-1">
|
|
408
|
+
<div className="flex gap-1">
|
|
409
|
+
<span className="typing-dot w-2 h-2 bg-gray-400 rounded-full inline-block" />
|
|
410
|
+
<span className="typing-dot w-2 h-2 bg-gray-400 rounded-full inline-block" />
|
|
411
|
+
<span className="typing-dot w-2 h-2 bg-gray-400 rounded-full inline-block" />
|
|
412
|
+
</div>
|
|
413
|
+
<span className="text-sm text-gray-400 ml-1">{t.thinkingLabel}</span>
|
|
414
|
+
</div>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// --- Welcome screen (no session selected) ---
|
|
419
|
+
|
|
420
|
+
function WelcomeScreen({ onNewSession, language }: { onNewSession: () => void; language: Language }) {
|
|
421
|
+
const t = UI_TEXT[language];
|
|
422
|
+
return (
|
|
423
|
+
<div className="flex-1 flex items-center justify-center bg-gradient-to-b from-gray-50 to-white px-6">
|
|
424
|
+
<div className="max-w-2xl w-full text-center">
|
|
425
|
+
<div className="mb-8">
|
|
426
|
+
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-blue-500 to-purple-600 text-white text-2xl font-bold mb-4 shadow-lg">
|
|
427
|
+
CW
|
|
428
|
+
</div>
|
|
429
|
+
<h1 className="text-2xl font-bold text-gray-800">{t.welcomeTitle}</h1>
|
|
430
|
+
<p className="text-gray-500 mt-2">{t.welcomeSubtitle}</p>
|
|
431
|
+
</div>
|
|
432
|
+
|
|
433
|
+
<div className="grid grid-cols-3 gap-4 mb-8 max-w-lg mx-auto">
|
|
434
|
+
{t.steps.map((title, i) => (
|
|
435
|
+
<div key={i} className="relative text-center">
|
|
436
|
+
{i > 0 && (
|
|
437
|
+
<div className="absolute left-0 top-5 -translate-x-1/2 w-full h-[2px]">
|
|
438
|
+
<div className="h-full bg-gray-200 mx-2" />
|
|
439
|
+
</div>
|
|
440
|
+
)}
|
|
441
|
+
<div className={`relative inline-flex items-center justify-center w-10 h-10 rounded-full ${STEP_COLORS[i]} text-white text-sm font-bold mb-2 shadow`}>
|
|
442
|
+
{i + 1}
|
|
443
|
+
</div>
|
|
444
|
+
<div className="text-sm font-semibold text-gray-700">{title}</div>
|
|
445
|
+
<div className="text-[11px] text-gray-400 mt-1 leading-tight px-1">
|
|
446
|
+
{t.stepsDesc[i]}
|
|
447
|
+
</div>
|
|
448
|
+
<div className="mt-1.5">
|
|
449
|
+
<span className="inline-block text-[10px] font-mono px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">
|
|
450
|
+
{STEP_TOOLS[i]}
|
|
451
|
+
</span>
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
))}
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
<button
|
|
458
|
+
onClick={onNewSession}
|
|
459
|
+
className="px-8 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors shadow-md hover:shadow-lg"
|
|
460
|
+
>
|
|
461
|
+
{t.welcomeCta}
|
|
462
|
+
</button>
|
|
463
|
+
<p className="text-xs text-gray-400 mt-3">{t.welcomeHint}</p>
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// --- Empty chat (session selected but no messages) ---
|
|
470
|
+
|
|
471
|
+
function EmptyChat({
|
|
472
|
+
onFillInput,
|
|
473
|
+
onSendMessage,
|
|
474
|
+
language,
|
|
475
|
+
}: {
|
|
476
|
+
onFillInput: (prompt: string, hint: string) => void;
|
|
477
|
+
onSendMessage: (content: string) => void;
|
|
478
|
+
language: Language;
|
|
479
|
+
}) {
|
|
480
|
+
const t = UI_TEXT[language];
|
|
481
|
+
const actions = PIPELINE_ACTIONS[language];
|
|
482
|
+
const chips = QUICK_CHIPS[language];
|
|
483
|
+
|
|
484
|
+
return (
|
|
485
|
+
<div className="flex-1 flex items-center justify-center px-6">
|
|
486
|
+
<div className="max-w-xl w-full">
|
|
487
|
+
<h2 className="text-lg font-semibold text-gray-700 text-center mb-1">
|
|
488
|
+
{t.emptyTitle}
|
|
489
|
+
</h2>
|
|
490
|
+
<p className="text-sm text-gray-400 text-center mb-6">
|
|
491
|
+
{t.emptySubtitle}
|
|
492
|
+
</p>
|
|
493
|
+
|
|
494
|
+
{/* Pipeline action cards */}
|
|
495
|
+
<div className="space-y-3">
|
|
496
|
+
{actions.map((action) => (
|
|
497
|
+
<button
|
|
498
|
+
key={action.label}
|
|
499
|
+
onClick={() => {
|
|
500
|
+
if (action.mode === "send") {
|
|
501
|
+
onSendMessage(action.prompt);
|
|
502
|
+
} else {
|
|
503
|
+
onFillInput(action.prompt, action.hint || "");
|
|
504
|
+
}
|
|
505
|
+
}}
|
|
506
|
+
className="group w-full text-left p-4 rounded-xl border border-gray-200 hover:border-blue-300 hover:shadow-md transition-all bg-white"
|
|
507
|
+
>
|
|
508
|
+
<div className="flex items-center gap-3">
|
|
509
|
+
<span className="text-2xl shrink-0">{action.icon}</span>
|
|
510
|
+
<div className="min-w-0">
|
|
511
|
+
<div className="font-medium text-gray-800 group-hover:text-blue-600 transition-colors">
|
|
512
|
+
{action.label}
|
|
513
|
+
</div>
|
|
514
|
+
<div className="text-sm text-gray-400 mt-0.5">
|
|
515
|
+
{action.description}
|
|
516
|
+
</div>
|
|
517
|
+
</div>
|
|
518
|
+
</div>
|
|
519
|
+
</button>
|
|
520
|
+
))}
|
|
521
|
+
</div>
|
|
522
|
+
|
|
523
|
+
{/* Divider */}
|
|
524
|
+
<div className="flex items-center gap-3 mt-6 mb-3">
|
|
525
|
+
<div className="flex-1 h-px bg-gray-200" />
|
|
526
|
+
<span className="text-xs text-gray-400 font-medium">{t.advancedTools}</span>
|
|
527
|
+
<div className="flex-1 h-px bg-gray-200" />
|
|
528
|
+
</div>
|
|
529
|
+
|
|
530
|
+
{/* Compact chips */}
|
|
531
|
+
<div className="flex flex-wrap justify-center gap-1.5">
|
|
532
|
+
{chips.map((chip) => (
|
|
533
|
+
<button
|
|
534
|
+
key={chip.label}
|
|
535
|
+
onClick={() => {
|
|
536
|
+
if (chip.prompt.endsWith(":") || chip.prompt.endsWith(": ")) {
|
|
537
|
+
onFillInput(chip.prompt, "");
|
|
538
|
+
} else {
|
|
539
|
+
onSendMessage(chip.prompt);
|
|
540
|
+
}
|
|
541
|
+
}}
|
|
542
|
+
className={`text-[11px] px-2.5 py-1 rounded-full border transition-colors font-medium ${chip.color}`}
|
|
543
|
+
>
|
|
544
|
+
{chip.label}
|
|
545
|
+
</button>
|
|
546
|
+
))}
|
|
547
|
+
</div>
|
|
548
|
+
</div>
|
|
549
|
+
</div>
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// --- Main ChatWindow ---
|
|
554
|
+
|
|
555
|
+
export function ChatWindow({
|
|
556
|
+
sessionId,
|
|
557
|
+
messages,
|
|
558
|
+
isConnected,
|
|
559
|
+
isLoading,
|
|
560
|
+
onSendMessage,
|
|
561
|
+
onInterrupt,
|
|
562
|
+
onShowFiles,
|
|
563
|
+
onShowPublish,
|
|
564
|
+
onNewSession,
|
|
565
|
+
onPreviewFile,
|
|
566
|
+
workspacePath,
|
|
567
|
+
showFilesActive,
|
|
568
|
+
language,
|
|
569
|
+
}: ChatWindowProps) {
|
|
570
|
+
const t = UI_TEXT[language];
|
|
571
|
+
const [input, setInput] = useState("");
|
|
572
|
+
const [inputHint, setInputHint] = useState("");
|
|
573
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
574
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
575
|
+
|
|
576
|
+
const handleFillInput = (prompt: string, hint: string) => {
|
|
577
|
+
setInput(prompt);
|
|
578
|
+
setInputHint(hint);
|
|
579
|
+
// Focus at end of text after React re-render
|
|
580
|
+
setTimeout(() => {
|
|
581
|
+
if (textareaRef.current) {
|
|
582
|
+
textareaRef.current.focus();
|
|
583
|
+
textareaRef.current.selectionStart = prompt.length;
|
|
584
|
+
textareaRef.current.selectionEnd = prompt.length;
|
|
585
|
+
}
|
|
586
|
+
}, 0);
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
useEffect(() => {
|
|
590
|
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
591
|
+
}, [messages]);
|
|
592
|
+
|
|
593
|
+
useEffect(() => {
|
|
594
|
+
if (textareaRef.current) {
|
|
595
|
+
textareaRef.current.style.height = "auto";
|
|
596
|
+
textareaRef.current.style.height =
|
|
597
|
+
Math.min(textareaRef.current.scrollHeight, 200) + "px";
|
|
598
|
+
}
|
|
599
|
+
}, [input]);
|
|
600
|
+
|
|
601
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
602
|
+
e.preventDefault();
|
|
603
|
+
if (!input.trim() || !sessionId || isLoading || !isConnected) return;
|
|
604
|
+
onSendMessage(input.trim());
|
|
605
|
+
setInput("");
|
|
606
|
+
setInputHint("");
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
610
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
611
|
+
e.preventDefault();
|
|
612
|
+
handleSubmit(e);
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
// No session selected -> welcome
|
|
617
|
+
if (!sessionId) {
|
|
618
|
+
return <WelcomeScreen onNewSession={onNewSession} language={language} />;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return (
|
|
622
|
+
<div className="flex-1 flex flex-col bg-white min-w-0">
|
|
623
|
+
{/* Header toolbar */}
|
|
624
|
+
<div className="px-4 py-2.5 border-b border-gray-200 flex items-center justify-between shrink-0">
|
|
625
|
+
<div className="flex items-center gap-2">
|
|
626
|
+
<h2 className="font-semibold text-gray-800 text-sm">Chat</h2>
|
|
627
|
+
{isConnected ? (
|
|
628
|
+
<span className="flex items-center gap-1 text-[10px] text-green-600 bg-green-50 px-1.5 py-0.5 rounded-full">
|
|
629
|
+
<span className="w-1.5 h-1.5 bg-green-500 rounded-full" />
|
|
630
|
+
Live
|
|
631
|
+
</span>
|
|
632
|
+
) : (
|
|
633
|
+
<span className="flex items-center gap-1 text-[10px] text-red-600 bg-red-50 px-1.5 py-0.5 rounded-full">
|
|
634
|
+
<span className="w-1.5 h-1.5 bg-red-500 rounded-full" />
|
|
635
|
+
Offline
|
|
636
|
+
</span>
|
|
637
|
+
)}
|
|
638
|
+
</div>
|
|
639
|
+
|
|
640
|
+
<div className="flex items-center gap-1.5">
|
|
641
|
+
{isLoading && (
|
|
642
|
+
<button
|
|
643
|
+
onClick={onInterrupt}
|
|
644
|
+
className="text-xs px-2.5 py-1 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors border border-red-200"
|
|
645
|
+
>
|
|
646
|
+
{t.stopBtn}
|
|
647
|
+
</button>
|
|
648
|
+
)}
|
|
649
|
+
<button
|
|
650
|
+
onClick={onShowFiles}
|
|
651
|
+
className={`text-xs px-2.5 py-1 rounded-lg transition-colors border ${
|
|
652
|
+
showFilesActive
|
|
653
|
+
? "bg-blue-50 text-blue-600 border-blue-200"
|
|
654
|
+
: "bg-gray-50 text-gray-600 border-gray-200 hover:bg-gray-100"
|
|
655
|
+
}`}
|
|
656
|
+
title="Toggle workspace file explorer"
|
|
657
|
+
>
|
|
658
|
+
<span className="flex items-center gap-1">
|
|
659
|
+
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
660
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
|
661
|
+
</svg>
|
|
662
|
+
{t.filesBtn}
|
|
663
|
+
</span>
|
|
664
|
+
</button>
|
|
665
|
+
<button
|
|
666
|
+
onClick={onShowPublish}
|
|
667
|
+
className="text-xs px-2.5 py-1 rounded-lg bg-gray-50 text-gray-600 border border-gray-200 hover:bg-gray-100 transition-colors"
|
|
668
|
+
title={t.publishBtn}
|
|
669
|
+
>
|
|
670
|
+
<span className="flex items-center gap-1">
|
|
671
|
+
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
672
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
|
673
|
+
</svg>
|
|
674
|
+
{t.publishBtn}
|
|
675
|
+
</span>
|
|
676
|
+
</button>
|
|
677
|
+
</div>
|
|
678
|
+
</div>
|
|
679
|
+
|
|
680
|
+
{/* Messages or Empty state */}
|
|
681
|
+
{messages.length === 0 ? (
|
|
682
|
+
<EmptyChat onFillInput={handleFillInput} onSendMessage={onSendMessage} language={language} />
|
|
683
|
+
) : (
|
|
684
|
+
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
|
685
|
+
{messages.map((msg) => {
|
|
686
|
+
if (msg.role === "tool_use") {
|
|
687
|
+
const toolName = msg.toolName || msg.tool_name || "unknown";
|
|
688
|
+
const toolId = msg.toolId || msg.tool_id || msg.id;
|
|
689
|
+
let toolInput = msg.toolInput || {};
|
|
690
|
+
if (!msg.toolInput && msg.tool_input) {
|
|
691
|
+
try {
|
|
692
|
+
toolInput = JSON.parse(msg.tool_input);
|
|
693
|
+
} catch {
|
|
694
|
+
toolInput = {};
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return (
|
|
698
|
+
<ToolUseBlock
|
|
699
|
+
key={msg.id}
|
|
700
|
+
toolName={toolName}
|
|
701
|
+
toolId={toolId}
|
|
702
|
+
toolInput={toolInput}
|
|
703
|
+
onPreviewFile={onPreviewFile}
|
|
704
|
+
/>
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
if (msg.role === "tool_result") {
|
|
708
|
+
return <ToolResultBlock key={msg.id} message={msg} />;
|
|
709
|
+
}
|
|
710
|
+
if (msg.role === "result") {
|
|
711
|
+
return <ResultBlock key={msg.id} message={msg} />;
|
|
712
|
+
}
|
|
713
|
+
if (msg.role === "user" || msg.role === "assistant") {
|
|
714
|
+
return <MessageBubble key={msg.id} message={msg} onPreviewFile={onPreviewFile} workspacePath={workspacePath} />;
|
|
715
|
+
}
|
|
716
|
+
return null;
|
|
717
|
+
})}
|
|
718
|
+
{isLoading && <TypingIndicator language={language} />}
|
|
719
|
+
<div ref={messagesEndRef} />
|
|
720
|
+
</div>
|
|
721
|
+
)}
|
|
722
|
+
|
|
723
|
+
{/* Input + Quick Chips */}
|
|
724
|
+
<div className="border-t border-gray-200 shrink-0 bg-white">
|
|
725
|
+
{/* Quick action chips — hide when empty chat shows its own chips */}
|
|
726
|
+
{messages.length > 0 && <div className="px-4 pt-3 pb-1.5 flex flex-wrap gap-1.5">
|
|
727
|
+
{QUICK_CHIPS[language].map((chip) => (
|
|
728
|
+
<button
|
|
729
|
+
key={chip.label}
|
|
730
|
+
disabled={!isConnected || isLoading}
|
|
731
|
+
onClick={() => {
|
|
732
|
+
if (chip.prompt.endsWith(":") || chip.prompt.endsWith(": ")) {
|
|
733
|
+
setInput(chip.prompt);
|
|
734
|
+
textareaRef.current?.focus();
|
|
735
|
+
} else {
|
|
736
|
+
onSendMessage(chip.prompt);
|
|
737
|
+
}
|
|
738
|
+
}}
|
|
739
|
+
className={`text-[11px] px-2.5 py-1 rounded-full border transition-colors font-medium disabled:opacity-40 disabled:cursor-not-allowed ${chip.color}`}
|
|
740
|
+
>
|
|
741
|
+
{chip.label}
|
|
742
|
+
</button>
|
|
743
|
+
))}
|
|
744
|
+
</div>}
|
|
745
|
+
|
|
746
|
+
{/* Input */}
|
|
747
|
+
<div className="px-4 pb-3">
|
|
748
|
+
<form onSubmit={handleSubmit} className="flex gap-2">
|
|
749
|
+
<textarea
|
|
750
|
+
ref={textareaRef}
|
|
751
|
+
value={input}
|
|
752
|
+
onChange={(e) => {
|
|
753
|
+
setInput(e.target.value);
|
|
754
|
+
if (inputHint && e.target.value.length > input.length) {
|
|
755
|
+
setInputHint("");
|
|
756
|
+
}
|
|
757
|
+
}}
|
|
758
|
+
onKeyDown={handleKeyDown}
|
|
759
|
+
placeholder={isConnected ? t.placeholder : t.placeholderOffline}
|
|
760
|
+
disabled={!isConnected || isLoading}
|
|
761
|
+
rows={1}
|
|
762
|
+
className="flex-1 px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 resize-none text-sm"
|
|
763
|
+
/>
|
|
764
|
+
<button
|
|
765
|
+
type="submit"
|
|
766
|
+
disabled={!input.trim() || !isConnected || isLoading}
|
|
767
|
+
className="px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors self-end text-sm font-medium"
|
|
768
|
+
>
|
|
769
|
+
{t.sendBtn}
|
|
770
|
+
</button>
|
|
771
|
+
</form>
|
|
772
|
+
{inputHint && (
|
|
773
|
+
<div className="mt-1.5 px-1 flex items-center gap-1.5">
|
|
774
|
+
<span className="text-[11px] text-blue-500 animate-pulse">▶</span>
|
|
775
|
+
<span className="text-[11px] text-blue-500">{inputHint}</span>
|
|
776
|
+
</div>
|
|
777
|
+
)}
|
|
778
|
+
<div className="flex items-center justify-between mt-1.5 px-1">
|
|
779
|
+
<span className="text-[10px] text-gray-400">
|
|
780
|
+
{t.shiftEnter}
|
|
781
|
+
</span>
|
|
782
|
+
<span className="text-[10px] text-gray-400">
|
|
783
|
+
{t.poweredBy}
|
|
784
|
+
</span>
|
|
785
|
+
</div>
|
|
786
|
+
</div>
|
|
787
|
+
</div>
|
|
788
|
+
</div>
|
|
789
|
+
);
|
|
790
|
+
}
|