ideaco 1.1.5
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/.dockerignore +33 -0
- package/.nvmrc +1 -0
- package/ARCHITECTURE.md +394 -0
- package/Dockerfile +50 -0
- package/LICENSE +29 -0
- package/README.md +206 -0
- package/bin/i18n.js +46 -0
- package/bin/ideaco.js +494 -0
- package/deploy.sh +15 -0
- package/docker-compose.yml +30 -0
- package/electron/main.cjs +986 -0
- package/electron/preload.cjs +14 -0
- package/electron/web-backends.cjs +854 -0
- package/jsconfig.json +8 -0
- package/next.config.mjs +34 -0
- package/package.json +134 -0
- package/postcss.config.mjs +6 -0
- package/public/demo/dashboard.png +0 -0
- package/public/demo/employee.png +0 -0
- package/public/demo/messages.png +0 -0
- package/public/demo/office.png +0 -0
- package/public/demo/requirement.png +0 -0
- package/public/logo.jpeg +0 -0
- package/public/logo.png +0 -0
- package/scripts/prepare-electron.js +67 -0
- package/scripts/release.js +76 -0
- package/src/app/api/agents/[agentId]/chat/route.js +70 -0
- package/src/app/api/agents/[agentId]/conversations/route.js +35 -0
- package/src/app/api/agents/[agentId]/route.js +106 -0
- package/src/app/api/avatar/route.js +104 -0
- package/src/app/api/browse-dir/route.js +44 -0
- package/src/app/api/chat/route.js +265 -0
- package/src/app/api/company/factory-reset/route.js +43 -0
- package/src/app/api/company/route.js +82 -0
- package/src/app/api/departments/[deptId]/agents/[agentId]/dismiss/route.js +19 -0
- package/src/app/api/departments/route.js +92 -0
- package/src/app/api/group-chat-loop/events/route.js +70 -0
- package/src/app/api/group-chat-loop/route.js +94 -0
- package/src/app/api/mailbox/route.js +100 -0
- package/src/app/api/messages/route.js +14 -0
- package/src/app/api/providers/[id]/configure/route.js +21 -0
- package/src/app/api/providers/[id]/refresh-cookie/route.js +38 -0
- package/src/app/api/providers/[id]/test-cookie/route.js +28 -0
- package/src/app/api/providers/route.js +11 -0
- package/src/app/api/requirements/route.js +242 -0
- package/src/app/api/secretary/route.js +65 -0
- package/src/app/api/system/cli-backends/route.js +91 -0
- package/src/app/api/system/cron/route.js +110 -0
- package/src/app/api/system/knowledge/route.js +104 -0
- package/src/app/api/system/plugins/route.js +40 -0
- package/src/app/api/system/skills/route.js +46 -0
- package/src/app/api/system/status/route.js +46 -0
- package/src/app/api/talent-market/[profileId]/recall/route.js +22 -0
- package/src/app/api/talent-market/[profileId]/route.js +17 -0
- package/src/app/api/talent-market/route.js +26 -0
- package/src/app/api/teams/route.js +773 -0
- package/src/app/api/ws-files/[departmentId]/file/route.js +27 -0
- package/src/app/api/ws-files/[departmentId]/files/route.js +22 -0
- package/src/app/globals.css +130 -0
- package/src/app/layout.jsx +40 -0
- package/src/app/page.jsx +97 -0
- package/src/components/AgentChatModal.jsx +164 -0
- package/src/components/AgentDetailModal.jsx +425 -0
- package/src/components/AgentSpyModal.jsx +481 -0
- package/src/components/AvatarGrid.jsx +29 -0
- package/src/components/BossProfileModal.jsx +162 -0
- package/src/components/CachedAvatar.jsx +77 -0
- package/src/components/ChatPanel.jsx +219 -0
- package/src/components/ChatShared.jsx +255 -0
- package/src/components/DepartmentDetail.jsx +842 -0
- package/src/components/DepartmentView.jsx +367 -0
- package/src/components/FileReference.jsx +260 -0
- package/src/components/FilesView.jsx +465 -0
- package/src/components/GroupChatView.jsx +799 -0
- package/src/components/Mailbox.jsx +926 -0
- package/src/components/MessagesView.jsx +112 -0
- package/src/components/OnboardingGuide.jsx +209 -0
- package/src/components/OrgTree.jsx +151 -0
- package/src/components/Overview.jsx +391 -0
- package/src/components/PixelOffice.jsx +2281 -0
- package/src/components/ProviderGrid.jsx +551 -0
- package/src/components/ProvidersBoard.jsx +16 -0
- package/src/components/RequirementDetail.jsx +1279 -0
- package/src/components/RequirementsBoard.jsx +187 -0
- package/src/components/SecretarySettings.jsx +295 -0
- package/src/components/SetupWizard.jsx +388 -0
- package/src/components/Sidebar.jsx +169 -0
- package/src/components/SystemMonitor.jsx +808 -0
- package/src/components/TalentMarket.jsx +183 -0
- package/src/components/TeamDetail.jsx +697 -0
- package/src/core/agent/base-agent.js +104 -0
- package/src/core/agent/chat-store.js +602 -0
- package/src/core/agent/cli-agent/backends/claude-code/README.md +52 -0
- package/src/core/agent/cli-agent/backends/claude-code/config.js +27 -0
- package/src/core/agent/cli-agent/backends/codebuddy/README.md +236 -0
- package/src/core/agent/cli-agent/backends/codebuddy/config.js +27 -0
- package/src/core/agent/cli-agent/backends/codex/README.md +51 -0
- package/src/core/agent/cli-agent/backends/codex/config.js +27 -0
- package/src/core/agent/cli-agent/backends/index.js +27 -0
- package/src/core/agent/cli-agent/backends/registry.js +580 -0
- package/src/core/agent/cli-agent/index.js +154 -0
- package/src/core/agent/index.js +60 -0
- package/src/core/agent/llm-agent/client.js +320 -0
- package/src/core/agent/llm-agent/index.js +97 -0
- package/src/core/agent/message-bus.js +211 -0
- package/src/core/agent/session.js +608 -0
- package/src/core/agent/tools.js +596 -0
- package/src/core/agent/web-agent/backends/base-backend.js +180 -0
- package/src/core/agent/web-agent/backends/chatgpt/client.js +146 -0
- package/src/core/agent/web-agent/backends/chatgpt/config.js +148 -0
- package/src/core/agent/web-agent/backends/chatgpt/dom-scripts.js +303 -0
- package/src/core/agent/web-agent/backends/index.js +91 -0
- package/src/core/agent/web-agent/index.js +278 -0
- package/src/core/agent/web-agent/web-client.js +407 -0
- package/src/core/employee/base-employee.js +1088 -0
- package/src/core/employee/index.js +35 -0
- package/src/core/employee/knowledge.js +327 -0
- package/src/core/employee/lifecycle.js +990 -0
- package/src/core/employee/memory/index.js +642 -0
- package/src/core/employee/memory/store.js +143 -0
- package/src/core/employee/performance.js +224 -0
- package/src/core/employee/secretary.js +625 -0
- package/src/core/employee/skills.js +398 -0
- package/src/core/index.js +38 -0
- package/src/core/organization/company.js +2600 -0
- package/src/core/organization/department.js +737 -0
- package/src/core/organization/group-chat-loop.js +264 -0
- package/src/core/organization/index.js +8 -0
- package/src/core/organization/persistence.js +111 -0
- package/src/core/organization/team.js +267 -0
- package/src/core/organization/workforce/hr.js +377 -0
- package/src/core/organization/workforce/providers.js +468 -0
- package/src/core/organization/workforce/role-archetypes.js +805 -0
- package/src/core/organization/workforce/talent-market.js +205 -0
- package/src/core/prompts.js +532 -0
- package/src/core/requirement.js +1789 -0
- package/src/core/system/audit.js +483 -0
- package/src/core/system/cron.js +449 -0
- package/src/core/system/index.js +7 -0
- package/src/core/system/plugin.js +2183 -0
- package/src/core/utils/json-parse.js +188 -0
- package/src/core/workspace.js +239 -0
- package/src/lib/api-i18n.js +211 -0
- package/src/lib/avatar.js +268 -0
- package/src/lib/client-store.js +1025 -0
- package/src/lib/config-validator.js +483 -0
- package/src/lib/format-time.js +22 -0
- package/src/lib/hooks.js +414 -0
- package/src/lib/i18n.js +134 -0
- package/src/lib/paths.js +23 -0
- package/src/lib/store.js +72 -0
- package/src/locales/de.js +393 -0
- package/src/locales/en.js +1054 -0
- package/src/locales/es.js +393 -0
- package/src/locales/fr.js +393 -0
- package/src/locales/ja.js +501 -0
- package/src/locales/ko.js +513 -0
- package/src/locales/zh.js +828 -0
- package/tailwind.config.mjs +11 -0
|
@@ -0,0 +1,1279 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
4
|
+
import { useStore } from '@/lib/client-store';
|
|
5
|
+
import { useI18n } from '@/lib/i18n';
|
|
6
|
+
import GroupChatView from './GroupChatView';
|
|
7
|
+
import AgentDetailModal from './AgentDetailModal';
|
|
8
|
+
import CachedAvatar from './CachedAvatar';
|
|
9
|
+
import FilesView from './FilesView';
|
|
10
|
+
import PixelOffice from './PixelOffice';
|
|
11
|
+
|
|
12
|
+
import { useRouter } from 'next/navigation';
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Requirement detail page
|
|
18
|
+
* Displays: requirement info, workflow DAG, group chat messages, output results
|
|
19
|
+
*/
|
|
20
|
+
export default function RequirementDetail({ requirementId, onClose }) {
|
|
21
|
+
const { t } = useI18n();
|
|
22
|
+
const { fetchRequirementDetail, requirementDetail, clearRequirementDetail, fetchWorkspaceFile, navigateBack, activeRequirementId, deleteRequirement, restartRequirement, sendGroupChatMessage, company } = useStore();
|
|
23
|
+
const reqId = requirementId || activeRequirementId;
|
|
24
|
+
const isPage = !onClose; // If no onClose is passed, it is standalone page mode
|
|
25
|
+
const [activeTab, setActiveTab] = useState('workflow'); // workflow | files
|
|
26
|
+
const chatEndRef = useRef(null);
|
|
27
|
+
const pollRef = useRef(null);
|
|
28
|
+
const [previewFile, setPreviewFile] = useState(null); // { path, content, loading }
|
|
29
|
+
|
|
30
|
+
// 群员交互:查看卡片信息 + 偷看心流
|
|
31
|
+
const [selectedMemberAgentId, setSelectedMemberAgentId] = useState(null);
|
|
32
|
+
const [peekFlowAgentId, setPeekFlowAgentId] = useState(null);
|
|
33
|
+
const [peekFlowData, setPeekFlowData] = useState(null);
|
|
34
|
+
const [peekFlowMsgs, setPeekFlowMsgs] = useState([]);
|
|
35
|
+
const [peekFlowThoughtMsgs, setPeekFlowThoughtMsgs] = useState([]); // 内心独白消息(monologue 类型)
|
|
36
|
+
const [peekFlowHistory, setPeekFlowHistory] = useState([]);
|
|
37
|
+
const [peekFlowLoading, setPeekFlowLoading] = useState(false);
|
|
38
|
+
const [peekFlowTab, setPeekFlowTab] = useState('thoughts');
|
|
39
|
+
|
|
40
|
+
// Save reqId to ref to avoid reading stale value in closure
|
|
41
|
+
const reqIdRef = useRef(reqId);
|
|
42
|
+
reqIdRef.current = reqId;
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!reqId) return;
|
|
46
|
+
fetchRequirementDetail(reqId);
|
|
47
|
+
// Speed up polling for executing requirements (2s)
|
|
48
|
+
pollRef.current = setInterval(() => {
|
|
49
|
+
fetchRequirementDetail(reqIdRef.current);
|
|
50
|
+
}, 2000);
|
|
51
|
+
return () => {
|
|
52
|
+
if (pollRef.current) {
|
|
53
|
+
clearInterval(pollRef.current);
|
|
54
|
+
pollRef.current = null;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
58
|
+
}, [reqId]);
|
|
59
|
+
|
|
60
|
+
// Clear all states on component unmount
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
return () => {
|
|
63
|
+
clearRequirementDetail();
|
|
64
|
+
if (pollRef.current) {
|
|
65
|
+
clearInterval(pollRef.current);
|
|
66
|
+
pollRef.current = null;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
// Stop polling completed requirements or reduce polling frequency
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (requirementDetail && (requirementDetail.status === 'completed' || requirementDetail.status === 'failed' || requirementDetail.status === 'pending_approval')) {
|
|
74
|
+
if (pollRef.current) {
|
|
75
|
+
clearInterval(pollRef.current);
|
|
76
|
+
// Reduce to 10s polling after completion (keep updated without too many resources)
|
|
77
|
+
pollRef.current = setInterval(() => {
|
|
78
|
+
fetchRequirementDetail(reqId);
|
|
79
|
+
}, 10000);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}, [requirementDetail?.status]);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (activeTab === 'chat') {
|
|
86
|
+
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
87
|
+
}
|
|
88
|
+
}, [activeTab, requirementDetail?.groupChat?.length]);
|
|
89
|
+
|
|
90
|
+
// File preview loading
|
|
91
|
+
const loadFilePreview = useCallback(async (filePath) => {
|
|
92
|
+
if (!requirementDetail?.departmentId) return;
|
|
93
|
+
setPreviewFile({ path: filePath, content: null, loading: true });
|
|
94
|
+
try {
|
|
95
|
+
const result = await fetchWorkspaceFile(requirementDetail.departmentId, filePath);
|
|
96
|
+
// Check if API returned an error (e.g. permission denied)
|
|
97
|
+
if (result?.error) {
|
|
98
|
+
setPreviewFile({
|
|
99
|
+
path: filePath,
|
|
100
|
+
content: `⚠ ${result.error}`,
|
|
101
|
+
loading: false,
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const fileContent = result?.content ?? result ?? null;
|
|
106
|
+
setPreviewFile({
|
|
107
|
+
path: filePath,
|
|
108
|
+
content: fileContent != null ? String(fileContent) : t('reqDetail.files.noContent'),
|
|
109
|
+
loading: false,
|
|
110
|
+
});
|
|
111
|
+
} catch {
|
|
112
|
+
setPreviewFile({ path: filePath, content: t('reqDetail.files.readFailed'), loading: false });
|
|
113
|
+
}
|
|
114
|
+
}, [requirementDetail?.departmentId]);
|
|
115
|
+
|
|
116
|
+
// 偷看员工心流
|
|
117
|
+
const peekMemberFlow = useCallback(async (agentId) => {
|
|
118
|
+
setPeekFlowAgentId(agentId);
|
|
119
|
+
setPeekFlowLoading(true);
|
|
120
|
+
setPeekFlowTab('thoughts');
|
|
121
|
+
try {
|
|
122
|
+
const [currentRes, historyRes, flowRes, thoughtRes] = await Promise.all([
|
|
123
|
+
fetch(`/api/group-chat-loop?agentId=${agentId}&groupId=${reqId}`),
|
|
124
|
+
fetch(`/api/group-chat-loop?agentId=${agentId}&groupId=${reqId}&history=1`),
|
|
125
|
+
fetch(`/api/group-chat-loop?agentId=${agentId}&groupId=${reqId}&flowMessages=1`),
|
|
126
|
+
fetch(`/api/group-chat-loop?agentId=${agentId}&groupId=${reqId}&monologueMessages=1`),
|
|
127
|
+
]);
|
|
128
|
+
const currentData = await currentRes.json();
|
|
129
|
+
const historyData = await historyRes.json();
|
|
130
|
+
const flowData = await flowRes.json();
|
|
131
|
+
const thoughtData = await thoughtRes.json();
|
|
132
|
+
setPeekFlowData(currentData.data);
|
|
133
|
+
setPeekFlowHistory(historyData.data || []);
|
|
134
|
+
setPeekFlowMsgs(flowData.data || []);
|
|
135
|
+
setPeekFlowThoughtMsgs(thoughtData.data || []);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
console.error('Failed to peek flow:', err);
|
|
138
|
+
} finally {
|
|
139
|
+
setPeekFlowLoading(false);
|
|
140
|
+
}
|
|
141
|
+
}, [reqId]);
|
|
142
|
+
|
|
143
|
+
// 自动刷新心流
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (!peekFlowAgentId || !reqId) return;
|
|
146
|
+
const timer = setInterval(async () => {
|
|
147
|
+
try {
|
|
148
|
+
const [res, flowRes, thoughtRes] = await Promise.all([
|
|
149
|
+
fetch(`/api/group-chat-loop?agentId=${peekFlowAgentId}&groupId=${reqId}`),
|
|
150
|
+
fetch(`/api/group-chat-loop?agentId=${peekFlowAgentId}&groupId=${reqId}&flowMessages=1`),
|
|
151
|
+
fetch(`/api/group-chat-loop?agentId=${peekFlowAgentId}&groupId=${reqId}&monologueMessages=1`),
|
|
152
|
+
]);
|
|
153
|
+
const data = await res.json();
|
|
154
|
+
const flowData = await flowRes.json();
|
|
155
|
+
const thoughtData = await thoughtRes.json();
|
|
156
|
+
if (data.data) setPeekFlowData(data.data);
|
|
157
|
+
if (flowData.data) setPeekFlowMsgs(flowData.data);
|
|
158
|
+
if (thoughtData.data) setPeekFlowThoughtMsgs(thoughtData.data);
|
|
159
|
+
} catch {}
|
|
160
|
+
}, 3000);
|
|
161
|
+
return () => clearInterval(timer);
|
|
162
|
+
}, [peekFlowAgentId, reqId]);
|
|
163
|
+
|
|
164
|
+
const handleClose = onClose || navigateBack;
|
|
165
|
+
|
|
166
|
+
if (!requirementDetail) {
|
|
167
|
+
return isPage ? (
|
|
168
|
+
<div className="flex items-center justify-center h-full">
|
|
169
|
+
<div className="card p-8">
|
|
170
|
+
<span className="animate-pulse text-[var(--muted)]">{t('common.loading')}</span>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
) : (
|
|
174
|
+
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center !m-0">
|
|
175
|
+
<div className="card p-8">
|
|
176
|
+
<span className="animate-pulse text-[var(--muted)]">{t('common.loading')}</span>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const req = requirementDetail;
|
|
183
|
+
const statusConfig = {
|
|
184
|
+
pending: { label: t('reqDetail.status.pending'), color: 'text-gray-400', bg: 'bg-gray-900/30' },
|
|
185
|
+
planning: { label: t('reqDetail.status.planning'), color: 'text-blue-400', bg: 'bg-blue-900/30' },
|
|
186
|
+
in_progress: { label: t('reqDetail.status.in_progress'), color: 'text-yellow-400', bg: 'bg-yellow-900/30' },
|
|
187
|
+
pending_approval: { label: t('reqDetail.status.pending_approval'), color: 'text-orange-400', bg: 'bg-orange-900/30' },
|
|
188
|
+
completed: { label: t('reqDetail.status.completed'), color: 'text-green-400', bg: 'bg-green-900/30' },
|
|
189
|
+
failed: { label: t('reqDetail.status.failed'), color: 'text-red-400', bg: 'bg-red-900/30' },
|
|
190
|
+
};
|
|
191
|
+
const st = statusConfig[req.status] || statusConfig.pending;
|
|
192
|
+
|
|
193
|
+
// Page mode: full-screen standalone page
|
|
194
|
+
if (isPage) {
|
|
195
|
+
return (
|
|
196
|
+
<div className="h-full flex flex-col animate-fade-in">
|
|
197
|
+
{/* Header navigation bar */}
|
|
198
|
+
<div className="px-6 py-4 border-b border-white/[0.06] bg-[var(--card)] flex items-start justify-between shrink-0">
|
|
199
|
+
<div className="flex items-center gap-4 flex-1 min-w-0">
|
|
200
|
+
<button
|
|
201
|
+
onClick={handleClose}
|
|
202
|
+
className="text-[var(--muted)] hover:text-white text-sm flex items-center gap-1 shrink-0 transition-colors hover:bg-white/5 px-2 py-1 rounded-lg"
|
|
203
|
+
>
|
|
204
|
+
← {t('reqDetail.backShort')}
|
|
205
|
+
</button>
|
|
206
|
+
<div className="w-px h-8 bg-white/[0.08]" />
|
|
207
|
+
<div className="flex-1 min-w-0">
|
|
208
|
+
<div className="flex items-center gap-3">
|
|
209
|
+
<h1 className="text-xl font-bold truncate">{req.title}</h1>
|
|
210
|
+
<span className={`text-xs px-2 py-0.5 rounded-full ${st.bg} ${st.color}`}>
|
|
211
|
+
{st.label}
|
|
212
|
+
</span>
|
|
213
|
+
{req.status === 'in_progress' && (
|
|
214
|
+
<span className="animate-pulse text-yellow-400 text-xs">{t('reqDetail.executingShort')}</span>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
<div className="flex items-center gap-4 mt-1 text-xs text-[var(--muted)] flex-wrap">
|
|
218
|
+
<span className="whitespace-nowrap">🏢 {req.departmentName}</span>
|
|
219
|
+
<span className="whitespace-nowrap">📅 {new Date(req.createdAt).toLocaleString()}</span>
|
|
220
|
+
<span className="truncate max-w-md">{req.description}</span>
|
|
221
|
+
{req.summary && (
|
|
222
|
+
<>
|
|
223
|
+
<span className="whitespace-nowrap">{t('reqDetail.summary.tasks', { n: req.summary.successTasks, total: req.summary.totalTasks })}</span>
|
|
224
|
+
<span className="whitespace-nowrap">{t('reqDetail.summary.duration', { n: Math.round((req.summary.totalDuration || 0) / 1000) })}</span>
|
|
225
|
+
</>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
<div className="flex items-center gap-2 shrink-0 ml-4 electron-no-drag relative z-[10000]">
|
|
231
|
+
<button
|
|
232
|
+
onClick={() => restartRequirement(req.id)}
|
|
233
|
+
className="text-sm px-4 py-2 rounded-lg bg-blue-600/15 hover:bg-blue-600/25 text-blue-400 border border-blue-500/20 transition-colors flex items-center gap-1.5 cursor-pointer min-h-[36px] select-none"
|
|
234
|
+
>
|
|
235
|
+
{t('reqDetail.live.restart')}
|
|
236
|
+
</button>
|
|
237
|
+
<button
|
|
238
|
+
onClick={() => { if (confirm(t('reqDetail.live.confirmDelete'))) deleteRequirement(req.id); }}
|
|
239
|
+
className="text-sm px-4 py-2 rounded-lg bg-red-600/15 hover:bg-red-600/25 text-red-400 border border-red-500/20 transition-colors flex items-center gap-1.5 cursor-pointer min-h-[36px] select-none"
|
|
240
|
+
>
|
|
241
|
+
{t('reqDetail.live.deleteReq')}
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
{/* 左右布局主体 */}
|
|
247
|
+
<div className="flex-1 min-h-0 flex">
|
|
248
|
+
{/* 左侧:群聊面板 */}
|
|
249
|
+
<div className="w-[380px] shrink-0 border-r border-white/[0.06] flex flex-col bg-[var(--background)]">
|
|
250
|
+
<div className="px-4 py-2.5 border-b border-white/[0.06] bg-[var(--card)] flex items-center justify-between">
|
|
251
|
+
<span className="text-sm font-medium">{t('reqDetail.tabs.chat')}</span>
|
|
252
|
+
<span className="text-[10px] bg-white/10 px-1.5 py-0.5 rounded-full text-[var(--muted)]">{(req.groupChat || []).length}</span>
|
|
253
|
+
</div>
|
|
254
|
+
{(() => {
|
|
255
|
+
const chatAgentMap = {};
|
|
256
|
+
if (company?.departments) {
|
|
257
|
+
for (const dept of company.departments) {
|
|
258
|
+
for (const agent of (dept.members || dept.agents || [])) {
|
|
259
|
+
chatAgentMap[agent.id] = agent.name;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const leaderMsg = (req.groupChat || []).find(m => m.from?.id !== 'boss' && m.from?.role !== 'system' && m.type !== 'system');
|
|
264
|
+
const chatLeaderInfo = leaderMsg ? { name: leaderMsg.from?.name, avatar: leaderMsg.from?.avatar } : null;
|
|
265
|
+
return (
|
|
266
|
+
<GroupChatView
|
|
267
|
+
groupChat={req.groupChat || []}
|
|
268
|
+
agentMap={chatAgentMap}
|
|
269
|
+
bossAvatar={company?.bossAvatar}
|
|
270
|
+
bossName={company?.boss || 'Boss'}
|
|
271
|
+
requirementId={req.id}
|
|
272
|
+
onSendMessage={sendGroupChatMessage}
|
|
273
|
+
fetchDetail={fetchRequirementDetail}
|
|
274
|
+
leaderInfo={chatLeaderInfo}
|
|
275
|
+
chatEndRef={chatEndRef}
|
|
276
|
+
embedded
|
|
277
|
+
inputPlaceholder={req.status === 'pending_approval' ? t('reqDetail.approvalHint') : undefined}
|
|
278
|
+
/>
|
|
279
|
+
);
|
|
280
|
+
})()}
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
{/* 右侧:群员列表 + Tab bar + Content */}
|
|
284
|
+
<div className="flex-1 min-w-0 flex flex-col">
|
|
285
|
+
{/* 群员列表 + 流程卡点 */}
|
|
286
|
+
<MembersAndBlockingPanel members={req.members} blockingInfo={req.blockingInfo} workflow={req.workflow} status={req.status} onPeekFlow={peekMemberFlow} onViewAgent={setSelectedMemberAgentId} />
|
|
287
|
+
|
|
288
|
+
{/* Tab bar (不含 chat,因为群聊已在左侧) */}
|
|
289
|
+
<div className="flex border-b border-white/[0.06] shrink-0 px-6 bg-[var(--card)]">
|
|
290
|
+
{[
|
|
291
|
+
{ id: 'workflow', label: t('reqDetail.tabs.workflow'), badge: req.workflow?.nodes?.length },
|
|
292
|
+
{ id: 'files', label: t('reqDetail.tabs.files'), badge: new Set((req.liveStatus?.recentFileChanges || []).filter(f => f.filePath).map(f => f.filePath.replace(/^\.[\/\\]/, ''))).size },
|
|
293
|
+
{ id: 'office', label: t('reqDetail.tabs.office') },
|
|
294
|
+
].map(tab => (
|
|
295
|
+
<button
|
|
296
|
+
key={tab.id}
|
|
297
|
+
onClick={() => setActiveTab(tab.id)}
|
|
298
|
+
className={`px-5 py-3 text-sm font-medium transition-all border-b-2 ${
|
|
299
|
+
activeTab === tab.id
|
|
300
|
+
? 'border-[var(--accent)] text-[var(--accent)]'
|
|
301
|
+
: 'border-transparent text-[var(--muted)] hover:text-white'
|
|
302
|
+
}`}
|
|
303
|
+
>
|
|
304
|
+
{tab.label}
|
|
305
|
+
{tab.badge > 0 && (
|
|
306
|
+
<span className="ml-1.5 text-[10px] bg-white/10 px-1.5 py-0.5 rounded-full">{tab.badge}</span>
|
|
307
|
+
)}
|
|
308
|
+
</button>
|
|
309
|
+
))}
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
{/* Content area */}
|
|
313
|
+
<div className={`flex-1 min-h-0 flex flex-col pb-6 ${activeTab === 'files' || activeTab === 'office' ? 'overflow-hidden' : 'overflow-auto'}`}>
|
|
314
|
+
{activeTab === 'workflow' && (
|
|
315
|
+
<WorkflowView workflow={req.workflow} liveStatus={req.liveStatus} members={req.members} />
|
|
316
|
+
)}
|
|
317
|
+
{activeTab === 'files' && (
|
|
318
|
+
<div className="flex-1 min-h-0">
|
|
319
|
+
<FilesView
|
|
320
|
+
fileChanges={req.liveStatus?.recentFileChanges || []}
|
|
321
|
+
departmentId={req.departmentId}
|
|
322
|
+
previewFile={previewFile}
|
|
323
|
+
onPreview={loadFilePreview}
|
|
324
|
+
onClosePreview={() => setPreviewFile(null)}
|
|
325
|
+
/>
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
{activeTab === 'office' && (
|
|
329
|
+
<div className="flex-1 min-h-0">
|
|
330
|
+
<PixelOffice embedded groupChat={req.groupChat} members={req.members} />
|
|
331
|
+
</div>
|
|
332
|
+
)}
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
|
|
337
|
+
{/* Agent 卡片弹窗 */}
|
|
338
|
+
{selectedMemberAgentId && (
|
|
339
|
+
<AgentDetailModal agentId={selectedMemberAgentId} onClose={() => setSelectedMemberAgentId(null)} />
|
|
340
|
+
)}
|
|
341
|
+
|
|
342
|
+
{/* 心流偷看弹窗 */}
|
|
343
|
+
{peekFlowAgentId && (
|
|
344
|
+
<FlowPeekModal
|
|
345
|
+
agentId={peekFlowAgentId}
|
|
346
|
+
agentName={req.members?.find(m => m.id === peekFlowAgentId)?.name || peekFlowAgentId}
|
|
347
|
+
loading={peekFlowLoading}
|
|
348
|
+
tab={peekFlowTab}
|
|
349
|
+
onTabChange={setPeekFlowTab}
|
|
350
|
+
flowMsgs={peekFlowMsgs}
|
|
351
|
+
monologueData={peekFlowData}
|
|
352
|
+
monologueThoughtMsgs={peekFlowThoughtMsgs}
|
|
353
|
+
history={peekFlowHistory}
|
|
354
|
+
onClose={() => setPeekFlowAgentId(null)}
|
|
355
|
+
/>
|
|
356
|
+
)}
|
|
357
|
+
</div>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Modal mode (backward compatible, but no longer used)
|
|
362
|
+
return (
|
|
363
|
+
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center !m-0" onClick={handleClose}>
|
|
364
|
+
<div
|
|
365
|
+
className="bg-[var(--card)] border border-[var(--border)] rounded-2xl max-w-5xl w-full mx-4 max-h-[90vh] min-h-[60vh] flex flex-col"
|
|
366
|
+
onClick={e => e.stopPropagation()}
|
|
367
|
+
>
|
|
368
|
+
{/* Header */}
|
|
369
|
+
<div className="px-6 py-4 border-b border-white/[0.06] flex items-start justify-between shrink-0">
|
|
370
|
+
<div className="flex-1 min-w-0">
|
|
371
|
+
<div className="flex items-center gap-3">
|
|
372
|
+
<h2 className="text-lg font-bold truncate">{req.title}</h2>
|
|
373
|
+
<span className={`text-xs px-2 py-0.5 rounded-full ${st.bg} ${st.color}`}>
|
|
374
|
+
{st.label}
|
|
375
|
+
</span>
|
|
376
|
+
{req.status === 'in_progress' && (
|
|
377
|
+
<span className="animate-pulse text-yellow-400 text-xs">{t('reqDetail.executingShort')}</span>
|
|
378
|
+
)}
|
|
379
|
+
</div>
|
|
380
|
+
<p className="text-sm text-[var(--muted)] mt-1 line-clamp-2">{req.description}</p>
|
|
381
|
+
<div className="flex items-center gap-4 mt-2 text-xs text-[var(--muted)] flex-wrap">
|
|
382
|
+
<span className="whitespace-nowrap">🏢 {req.departmentName}</span>
|
|
383
|
+
<span className="whitespace-nowrap">📅 {new Date(req.createdAt).toLocaleString()}</span>
|
|
384
|
+
{req.summary && (
|
|
385
|
+
<>
|
|
386
|
+
<span className="whitespace-nowrap">✅ {req.summary.successTasks}/{req.summary.totalTasks} {t('reqDetail.summary.tasks', { n: req.summary.successTasks, total: req.summary.totalTasks }).replace(/✅ \d+\/\d+ /, '')}</span>
|
|
387
|
+
<span className="whitespace-nowrap">⏱️ {Math.round((req.summary.totalDuration || 0) / 1000)}s</span>
|
|
388
|
+
</>
|
|
389
|
+
)}
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
<button onClick={handleClose} className="text-[var(--muted)] hover:text-white text-xl ml-4 shrink-0">✕</button>
|
|
393
|
+
</div>
|
|
394
|
+
|
|
395
|
+
{/* 群员列表 + 流程卡点 */}
|
|
396
|
+
<MembersAndBlockingPanel members={req.members} blockingInfo={req.blockingInfo} workflow={req.workflow} status={req.status} onPeekFlow={peekMemberFlow} onViewAgent={setSelectedMemberAgentId} />
|
|
397
|
+
|
|
398
|
+
{/* Tab bar */}
|
|
399
|
+
<div className="flex border-b border-white/[0.06] shrink-0 px-6">
|
|
400
|
+
{[
|
|
401
|
+
{ id: 'workflow', label: t('reqDetail.tabs.workflow'), badge: req.workflow?.nodes?.length },
|
|
402
|
+
{ id: 'chat', label: t('reqDetail.tabs.chat'), badge: req.groupChat?.length },
|
|
403
|
+
{ id: 'files', label: t('reqDetail.tabs.files'), badge: new Set((req.liveStatus?.recentFileChanges || []).filter(f => f.filePath).map(f => f.filePath.replace(/^\.[\/\\]/, ''))).size },
|
|
404
|
+
{ id: 'office', label: t('reqDetail.tabs.office') },
|
|
405
|
+
].map(tab => (
|
|
406
|
+
<button
|
|
407
|
+
key={tab.id}
|
|
408
|
+
onClick={() => setActiveTab(tab.id)}
|
|
409
|
+
className={`px-5 py-3 text-sm font-medium transition-all border-b-2 ${
|
|
410
|
+
activeTab === tab.id
|
|
411
|
+
? 'border-[var(--accent)] text-[var(--accent)]'
|
|
412
|
+
: 'border-transparent text-[var(--muted)] hover:text-white'
|
|
413
|
+
}`}
|
|
414
|
+
>
|
|
415
|
+
{tab.label}
|
|
416
|
+
{tab.badge > 0 && (
|
|
417
|
+
<span className="ml-1.5 text-[10px] bg-white/10 px-1.5 py-0.5 rounded-full">{tab.badge}</span>
|
|
418
|
+
)}
|
|
419
|
+
</button>
|
|
420
|
+
))}
|
|
421
|
+
</div>
|
|
422
|
+
|
|
423
|
+
{/* Content area */}
|
|
424
|
+
<div className={`flex-1 min-h-0 flex flex-col pb-6 ${activeTab === 'files' || activeTab === 'office' ? 'overflow-hidden' : 'overflow-auto'}`} style={{ minHeight: activeTab === 'files' || activeTab === 'office' ? '400px' : undefined }}>
|
|
425
|
+
{activeTab === 'workflow' && (
|
|
426
|
+
<WorkflowView workflow={req.workflow} liveStatus={req.liveStatus} members={req.members} />
|
|
427
|
+
)}
|
|
428
|
+
{activeTab === 'chat' && (() => {
|
|
429
|
+
const chatAgentMap = {};
|
|
430
|
+
if (company?.departments) {
|
|
431
|
+
for (const dept of company.departments) {
|
|
432
|
+
for (const agent of (dept.members || dept.agents || [])) {
|
|
433
|
+
chatAgentMap[agent.id] = agent.name;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const leaderMsg = (req.groupChat || []).find(m => m.from?.id !== 'boss' && m.from?.role !== 'system' && m.type !== 'system');
|
|
438
|
+
const chatLeaderInfo = leaderMsg ? { name: leaderMsg.from?.name, avatar: leaderMsg.from?.avatar } : null;
|
|
439
|
+
return (
|
|
440
|
+
<GroupChatView
|
|
441
|
+
groupChat={req.groupChat || []}
|
|
442
|
+
agentMap={chatAgentMap}
|
|
443
|
+
bossAvatar={company?.bossAvatar}
|
|
444
|
+
bossName={company?.boss || 'Boss'}
|
|
445
|
+
requirementId={req.id}
|
|
446
|
+
onSendMessage={sendGroupChatMessage}
|
|
447
|
+
fetchDetail={fetchRequirementDetail}
|
|
448
|
+
leaderInfo={chatLeaderInfo}
|
|
449
|
+
chatEndRef={chatEndRef}
|
|
450
|
+
embedded
|
|
451
|
+
inputPlaceholder={req.status === 'pending_approval' ? t('reqDetail.approvalHint') : undefined}
|
|
452
|
+
/>
|
|
453
|
+
);
|
|
454
|
+
})()}
|
|
455
|
+
{activeTab === 'files' && (
|
|
456
|
+
<div className="flex-1 min-h-0">
|
|
457
|
+
<FilesView
|
|
458
|
+
fileChanges={req.liveStatus?.recentFileChanges || []}
|
|
459
|
+
departmentId={req.departmentId}
|
|
460
|
+
previewFile={previewFile}
|
|
461
|
+
onPreview={loadFilePreview}
|
|
462
|
+
onClosePreview={() => setPreviewFile(null)}
|
|
463
|
+
/>
|
|
464
|
+
</div>
|
|
465
|
+
)}
|
|
466
|
+
{activeTab === 'office' && (
|
|
467
|
+
<div className="flex-1 min-h-0">
|
|
468
|
+
<PixelOffice embedded groupChat={req.groupChat} members={req.members} />
|
|
469
|
+
</div>
|
|
470
|
+
)}
|
|
471
|
+
</div>
|
|
472
|
+
|
|
473
|
+
{/* Agent 卡片弹窗 */}
|
|
474
|
+
{selectedMemberAgentId && (
|
|
475
|
+
<AgentDetailModal agentId={selectedMemberAgentId} onClose={() => setSelectedMemberAgentId(null)} />
|
|
476
|
+
)}
|
|
477
|
+
|
|
478
|
+
{/* 心流偷看弹窗 */}
|
|
479
|
+
{peekFlowAgentId && (
|
|
480
|
+
<FlowPeekModal
|
|
481
|
+
agentId={peekFlowAgentId}
|
|
482
|
+
agentName={req.members?.find(m => m.id === peekFlowAgentId)?.name || peekFlowAgentId}
|
|
483
|
+
loading={peekFlowLoading}
|
|
484
|
+
tab={peekFlowTab}
|
|
485
|
+
onTabChange={setPeekFlowTab}
|
|
486
|
+
flowMsgs={peekFlowMsgs}
|
|
487
|
+
monologueData={peekFlowData}
|
|
488
|
+
monologueThoughtMsgs={peekFlowThoughtMsgs}
|
|
489
|
+
history={peekFlowHistory}
|
|
490
|
+
onClose={() => setPeekFlowAgentId(null)}
|
|
491
|
+
/>
|
|
492
|
+
)}
|
|
493
|
+
</div>
|
|
494
|
+
</div>
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Workflow visualization - SVG flowchart + div cards (foreignObject)
|
|
501
|
+
* Multi-arrow merging: multiple incoming edges merge into one vertical line then connect to target
|
|
502
|
+
*/
|
|
503
|
+
function WorkflowView({ workflow, liveStatus, members }) {
|
|
504
|
+
const { t } = useI18n();
|
|
505
|
+
const containerRef = useRef(null);
|
|
506
|
+
const svgRef = useRef(null);
|
|
507
|
+
const measureRef = useRef(null);
|
|
508
|
+
const [containerWidth, setContainerWidth] = useState(800);
|
|
509
|
+
const [hoveredNode, setHoveredNode] = useState(null); // { node, rect }
|
|
510
|
+
const [measuredHeights, setMeasuredHeights] = useState({}); // nodeId -> height
|
|
511
|
+
const [measureTick, setMeasureTick] = useState(0); // Used to trigger re-measurement
|
|
512
|
+
|
|
513
|
+
// Watch container width
|
|
514
|
+
useEffect(() => {
|
|
515
|
+
if (!containerRef.current) return;
|
|
516
|
+
const obs = new ResizeObserver(entries => {
|
|
517
|
+
const w = entries[0]?.contentRect?.width;
|
|
518
|
+
if (w) setContainerWidth(w);
|
|
519
|
+
});
|
|
520
|
+
obs.observe(containerRef.current);
|
|
521
|
+
return () => obs.disconnect();
|
|
522
|
+
}, []);
|
|
523
|
+
|
|
524
|
+
// MutationObserver: watch measurement container DOM changes, auto re-measure with debounce
|
|
525
|
+
useEffect(() => {
|
|
526
|
+
if (!measureRef.current) return;
|
|
527
|
+
let timer = null;
|
|
528
|
+
const obs = new MutationObserver(() => {
|
|
529
|
+
clearTimeout(timer);
|
|
530
|
+
timer = setTimeout(() => setMeasureTick(t => t + 1), 100);
|
|
531
|
+
});
|
|
532
|
+
obs.observe(measureRef.current, { childList: true, subtree: true, characterData: true, attributes: true });
|
|
533
|
+
return () => { obs.disconnect(); clearTimeout(timer); };
|
|
534
|
+
}, [workflow]);
|
|
535
|
+
|
|
536
|
+
// Measure actual heights of all cards
|
|
537
|
+
useEffect(() => {
|
|
538
|
+
if (!measureRef.current) return;
|
|
539
|
+
const cards = measureRef.current.querySelectorAll('[data-node-id]');
|
|
540
|
+
const heights = {};
|
|
541
|
+
cards.forEach(card => {
|
|
542
|
+
const id = card.getAttribute('data-node-id');
|
|
543
|
+
if (id) {
|
|
544
|
+
// scrollHeight gets actual content height
|
|
545
|
+
heights[id] = Math.max(card.scrollHeight, 60); // minimum 60px
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
setMeasuredHeights(prev => {
|
|
549
|
+
// Only update when actually changed to avoid infinite loop
|
|
550
|
+
const changed = Object.keys(heights).some(k => prev[k] !== heights[k]) ||
|
|
551
|
+
Object.keys(heights).length !== Object.keys(prev).length;
|
|
552
|
+
return changed ? heights : prev;
|
|
553
|
+
});
|
|
554
|
+
}, [workflow, liveStatus, containerWidth, measureTick]);
|
|
555
|
+
|
|
556
|
+
// Topological layering + layout calculation (based on measured heights)
|
|
557
|
+
const layout = useMemo(() => {
|
|
558
|
+
if (!workflow?.nodes?.length) return null;
|
|
559
|
+
const nodes = workflow.nodes;
|
|
560
|
+
|
|
561
|
+
// Topological layering
|
|
562
|
+
const levels = [];
|
|
563
|
+
const placed = new Set();
|
|
564
|
+
let remaining = [...nodes];
|
|
565
|
+
while (remaining.length > 0) {
|
|
566
|
+
const level = remaining.filter(n => n.dependencies.every(d => placed.has(d)));
|
|
567
|
+
if (level.length === 0) { levels.push(remaining); break; }
|
|
568
|
+
levels.push(level);
|
|
569
|
+
level.forEach(n => placed.add(n.id));
|
|
570
|
+
remaining = remaining.filter(n => !placed.has(n.id));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Layout parameters
|
|
574
|
+
const nodeW = 280;
|
|
575
|
+
const defaultH = 90; // Default height (initial value before measurement)
|
|
576
|
+
const padding = 8; // Extra padding
|
|
577
|
+
const gapX = 32, gapY = 80;
|
|
578
|
+
const padX = 40, padY = 40;
|
|
579
|
+
|
|
580
|
+
// Calculate the minimum width needed by the widest layer
|
|
581
|
+
let maxLayerW = 0;
|
|
582
|
+
levels.forEach((level) => {
|
|
583
|
+
const layerW = level.length * nodeW + (level.length - 1) * gapX;
|
|
584
|
+
if (layerW > maxLayerW) maxLayerW = layerW;
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// Effective width: expand to fit all parallel nodes, allowing horizontal scroll
|
|
588
|
+
const effectiveW = Math.max(containerWidth - padX * 2, 600, maxLayerW);
|
|
589
|
+
|
|
590
|
+
// Calculate node positions per layer (centered, height from measurement)
|
|
591
|
+
const nodePositions = {};
|
|
592
|
+
let cumulativeY = padY;
|
|
593
|
+
levels.forEach((level, li) => {
|
|
594
|
+
// Max height of this layer = tallest measured card height in this layer
|
|
595
|
+
const layerH = Math.max(...level.map(n => (measuredHeights[n.id] || defaultH) + padding));
|
|
596
|
+
const totalW = level.length * nodeW + (level.length - 1) * gapX;
|
|
597
|
+
const startX = padX + (effectiveW - totalW) / 2;
|
|
598
|
+
level.forEach((node, ni) => {
|
|
599
|
+
const h = (measuredHeights[node.id] || defaultH) + padding;
|
|
600
|
+
nodePositions[node.id] = {
|
|
601
|
+
x: Math.max(padX, startX + ni * (nodeW + gapX)),
|
|
602
|
+
y: cumulativeY,
|
|
603
|
+
w: nodeW,
|
|
604
|
+
h,
|
|
605
|
+
node,
|
|
606
|
+
level: li,
|
|
607
|
+
};
|
|
608
|
+
});
|
|
609
|
+
cumulativeY += layerH + gapY;
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
const totalHeight = cumulativeY - gapY + padY;
|
|
613
|
+
const totalWidth = Math.max(effectiveW + padX * 2, containerWidth);
|
|
614
|
+
|
|
615
|
+
// Build edge merge info
|
|
616
|
+
const edgeGroups = {}; // targetId -> [fromId, ...]
|
|
617
|
+
nodes.forEach(node => {
|
|
618
|
+
if (node.dependencies.length > 0) {
|
|
619
|
+
edgeGroups[node.id] = node.dependencies.filter(d => nodePositions[d]);
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
return { levels, nodePositions, edgeGroups, totalWidth, totalHeight, nodeW, nodes };
|
|
624
|
+
}, [workflow, containerWidth, measuredHeights]);
|
|
625
|
+
|
|
626
|
+
// Build member avatar map { id -> avatar } (must be before conditional returns to satisfy hooks rules)
|
|
627
|
+
const memberAvatarMap = useMemo(() => {
|
|
628
|
+
const map = {};
|
|
629
|
+
if (members?.length) {
|
|
630
|
+
members.forEach(m => { if (m.avatar) map[m.id] = m.avatar; });
|
|
631
|
+
}
|
|
632
|
+
return map;
|
|
633
|
+
}, [members]);
|
|
634
|
+
|
|
635
|
+
if (!workflow?.nodes?.length) {
|
|
636
|
+
// planning 阶段:展示负责人头像 + 气泡(表示正在拆解任务)
|
|
637
|
+
if (liveStatus?.currentAgentAvatar || liveStatus?.currentAgent) {
|
|
638
|
+
return (
|
|
639
|
+
<div className="flex items-start gap-3 px-6 py-8">
|
|
640
|
+
<div className="relative shrink-0">
|
|
641
|
+
{liveStatus.currentAgentAvatar ? (
|
|
642
|
+
<CachedAvatar src={liveStatus.currentAgentAvatar} alt="" className="w-10 h-10 rounded-full bg-[var(--border)]" />
|
|
643
|
+
) : (
|
|
644
|
+
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-sm">🤖</div>
|
|
645
|
+
)}
|
|
646
|
+
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-green-500 rounded-full border-2 border-[var(--card)]" />
|
|
647
|
+
</div>
|
|
648
|
+
<div className="flex-1">
|
|
649
|
+
<div className="text-xs text-[var(--muted)] mb-1 font-medium">{liveStatus.currentAgent || t('reqDetail.workflow.leader')}</div>
|
|
650
|
+
<div className="inline-block bg-[var(--card)] border border-[var(--border)] rounded-2xl rounded-tl-sm px-4 py-3 text-sm">
|
|
651
|
+
<div className="flex items-center gap-2">
|
|
652
|
+
<span className="animate-pulse">🧠</span>
|
|
653
|
+
<span className="text-[var(--muted)]">{liveStatus.currentAction || t('reqDetail.workflow.planning')}</span>
|
|
654
|
+
</div>
|
|
655
|
+
<div className="flex items-center gap-1 mt-2">
|
|
656
|
+
<span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
|
657
|
+
<span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
|
658
|
+
<span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
|
659
|
+
</div>
|
|
660
|
+
</div>
|
|
661
|
+
</div>
|
|
662
|
+
</div>
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
return (
|
|
666
|
+
<div className="flex items-center justify-center py-16 text-[var(--muted)]">
|
|
667
|
+
<div className="text-center">
|
|
668
|
+
<div className="text-4xl mb-2">📋</div>
|
|
669
|
+
<p>{t('reqDetail.workflow.notParsed')}</p>
|
|
670
|
+
</div>
|
|
671
|
+
</div>
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (!layout) return null;
|
|
676
|
+
|
|
677
|
+
const { nodePositions, edgeGroups, totalWidth, totalHeight, nodes } = layout;
|
|
678
|
+
|
|
679
|
+
const statusIcon = {
|
|
680
|
+
waiting: '⏳', ready: '🔵', running: '🔄', reviewing: '🔍', revision: '🔄', completed: '✅', failed: '❌',
|
|
681
|
+
};
|
|
682
|
+
const statusBorderColor = {
|
|
683
|
+
waiting: '#4b5563', ready: '#3b82f6', running: '#eab308', reviewing: '#8b5cf6', revision: '#f59e0b', completed: '#22c55e', failed: '#ef4444',
|
|
684
|
+
};
|
|
685
|
+
const statusColor = {
|
|
686
|
+
waiting: 'border-gray-600',
|
|
687
|
+
ready: 'border-blue-500',
|
|
688
|
+
running: 'border-yellow-500 animate-pulse',
|
|
689
|
+
reviewing: 'border-purple-500 animate-pulse',
|
|
690
|
+
revision: 'border-orange-500 animate-pulse',
|
|
691
|
+
completed: 'border-green-500',
|
|
692
|
+
failed: 'border-red-500',
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
// Card content render function (shared by measurement container and actual render)
|
|
696
|
+
const renderCardContent = (node) => {
|
|
697
|
+
const assigneeAvatar = memberAvatarMap[node.assigneeId];
|
|
698
|
+
const reviewerAvatar = memberAvatarMap[node.reviewerId];
|
|
699
|
+
return (
|
|
700
|
+
<>
|
|
701
|
+
<div className="flex items-start justify-between">
|
|
702
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
703
|
+
<span className="text-base shrink-0">{statusIcon[node.status]}</span>
|
|
704
|
+
<div className="min-w-0">
|
|
705
|
+
<div className="font-medium text-sm truncate">{node.title}</div>
|
|
706
|
+
<div className="flex items-center gap-1 text-xs text-[var(--muted)]">
|
|
707
|
+
{assigneeAvatar ? (
|
|
708
|
+
<CachedAvatar src={assigneeAvatar} alt="" className="w-4 h-4 rounded-full inline-block" />
|
|
709
|
+
) : (
|
|
710
|
+
<span className="w-4 h-4 rounded-full bg-gradient-to-br from-indigo-600 to-blue-700 flex items-center justify-center text-[8px] shrink-0">
|
|
711
|
+
{node.assigneeName?.charAt(0) || '?'}
|
|
712
|
+
</span>
|
|
713
|
+
)}
|
|
714
|
+
<span>{node.assigneeName}</span>
|
|
715
|
+
{node.reviewerName && (
|
|
716
|
+
<>
|
|
717
|
+
<span className="text-[var(--muted)]">·</span>
|
|
718
|
+
<span>🔍</span>
|
|
719
|
+
{reviewerAvatar ? (
|
|
720
|
+
<CachedAvatar src={reviewerAvatar} alt="" className="w-4 h-4 rounded-full inline-block" />
|
|
721
|
+
) : (
|
|
722
|
+
<span className="w-4 h-4 rounded-full bg-gradient-to-br from-purple-600 to-pink-700 flex items-center justify-center text-[8px] shrink-0">
|
|
723
|
+
{node.reviewerName?.charAt(0) || '?'}
|
|
724
|
+
</span>
|
|
725
|
+
)}
|
|
726
|
+
<span>{node.reviewerName}</span>
|
|
727
|
+
</>
|
|
728
|
+
)}
|
|
729
|
+
</div>
|
|
730
|
+
</div>
|
|
731
|
+
</div>
|
|
732
|
+
{node.reviewRounds > 0 && (
|
|
733
|
+
<span className="text-[10px] bg-purple-500/20 text-purple-300 px-1.5 py-0.5 rounded shrink-0 ml-1">
|
|
734
|
+
R{node.reviewRounds}
|
|
735
|
+
</span>
|
|
736
|
+
)}
|
|
737
|
+
{node.completedAt && node.startedAt && (
|
|
738
|
+
<span className="text-[10px] text-[var(--muted)] shrink-0 ml-1">
|
|
739
|
+
{Math.round((new Date(node.completedAt) - new Date(node.startedAt)) / 1000)}s
|
|
740
|
+
</span>
|
|
741
|
+
)}
|
|
742
|
+
</div>
|
|
743
|
+
{node.description && (
|
|
744
|
+
<p className="text-xs text-[var(--muted)] mt-1.5 line-clamp-2">{node.description}</p>
|
|
745
|
+
)}
|
|
746
|
+
{/* Live action hint */}
|
|
747
|
+
{(node.status === 'running' || node.status === 'reviewing' || node.status === 'revision') && liveStatus?.currentNodeId === node.id && liveStatus.currentAction && (
|
|
748
|
+
<div className={`mt-1.5 rounded-lg px-2 py-1 text-[10px] flex items-center gap-1 overflow-hidden ${
|
|
749
|
+
node.status === 'reviewing' ? 'bg-purple-900/10 border border-purple-500/20 text-purple-300' :
|
|
750
|
+
node.status === 'revision' ? 'bg-orange-900/10 border border-orange-500/20 text-orange-300' :
|
|
751
|
+
'bg-yellow-900/10 border border-yellow-500/20 text-yellow-300'
|
|
752
|
+
}`}>
|
|
753
|
+
<span className="animate-spin text-xs shrink-0">{node.status === 'reviewing' ? '🔍' : node.status === 'revision' ? '✏️' : '⚙️'}</span>
|
|
754
|
+
<span className="truncate">{liveStatus.currentAction}</span>
|
|
755
|
+
</div>
|
|
756
|
+
)}
|
|
757
|
+
{/* Tool call progress */}
|
|
758
|
+
{node.status === 'running' && liveStatus?.currentNodeId === node.id && liveStatus.toolCallsInProgress?.length > 0 && (
|
|
759
|
+
<div className="mt-1 flex gap-1 flex-wrap">
|
|
760
|
+
{liveStatus.toolCallsInProgress.slice(0, 3).map((tool, ti) => (
|
|
761
|
+
<span key={ti} className="text-[10px] bg-purple-900/30 text-purple-400 px-1.5 py-0.5 rounded animate-pulse">
|
|
762
|
+
🔧 {tool}
|
|
763
|
+
</span>
|
|
764
|
+
))}
|
|
765
|
+
</div>
|
|
766
|
+
)}
|
|
767
|
+
</>
|
|
768
|
+
);
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
// Generate connection paths: multiple incoming edges merge into one vertical line
|
|
772
|
+
const renderEdges = () => {
|
|
773
|
+
const paths = [];
|
|
774
|
+
const mergeGap = 25; // Distance from merge point to target node top
|
|
775
|
+
|
|
776
|
+
Object.entries(edgeGroups).forEach(([targetId, fromIds]) => {
|
|
777
|
+
const to = nodePositions[targetId];
|
|
778
|
+
if (!to) return;
|
|
779
|
+
|
|
780
|
+
const toCenterX = to.x + to.w / 2;
|
|
781
|
+
const toTopY = to.y;
|
|
782
|
+
const mergeY = toTopY - mergeGap; // Merge point Y coordinate
|
|
783
|
+
|
|
784
|
+
// Determine edge status color
|
|
785
|
+
const getEdgeStyle = (fromId) => {
|
|
786
|
+
const fromNode = nodePositions[fromId]?.node;
|
|
787
|
+
const toNode = to.node;
|
|
788
|
+
const bothDone = fromNode?.status === 'completed' && toNode?.status === 'completed';
|
|
789
|
+
const isActive = fromNode?.status === 'completed' && toNode?.status === 'running';
|
|
790
|
+
return {
|
|
791
|
+
color: bothDone ? '#22c55e' : isActive ? '#eab308' : '#4b5563',
|
|
792
|
+
width: 1,
|
|
793
|
+
isActive,
|
|
794
|
+
};
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
if (fromIds.length === 1) {
|
|
798
|
+
// Single incoming edge: direct Bezier curve connection
|
|
799
|
+
const fromId = fromIds[0];
|
|
800
|
+
const from = nodePositions[fromId];
|
|
801
|
+
if (!from) return;
|
|
802
|
+
const style = getEdgeStyle(fromId);
|
|
803
|
+
const x1 = from.x + from.w / 2;
|
|
804
|
+
const y1 = from.y + from.h;
|
|
805
|
+
const x2 = toCenterX;
|
|
806
|
+
const y2 = toTopY;
|
|
807
|
+
const cy = (y1 + y2) / 2;
|
|
808
|
+
paths.push(
|
|
809
|
+
<path
|
|
810
|
+
key={`edge-${fromId}-${targetId}`}
|
|
811
|
+
d={`M${x1},${y1} C${x1},${cy} ${x2},${cy} ${x2},${y2}`}
|
|
812
|
+
fill="none"
|
|
813
|
+
stroke={style.color}
|
|
814
|
+
strokeWidth={style.width}
|
|
815
|
+
strokeLinecap="round"
|
|
816
|
+
markerEnd={`url(#arrow-${style.color.replace('#', '')})`}
|
|
817
|
+
className={style.isActive ? 'edge-running' : ''}
|
|
818
|
+
/>
|
|
819
|
+
);
|
|
820
|
+
} else {
|
|
821
|
+
// Multi-edge merge: all edges merge above target, then connect with one vertical line + arrow
|
|
822
|
+
// Use unified color to avoid inconsistency when edges overlap
|
|
823
|
+
const allStyles = fromIds.map(fid => getEdgeStyle(fid));
|
|
824
|
+
const bestStyle = allStyles.find(s => s.isActive) || allStyles.find(s => s.color === '#22c55e') || allStyles[0];
|
|
825
|
+
|
|
826
|
+
// 1. Merge vertical line: from mergeY to toTopY (unified color)
|
|
827
|
+
paths.push(
|
|
828
|
+
<path
|
|
829
|
+
key={`merge-vert-${targetId}`}
|
|
830
|
+
d={`M${toCenterX},${mergeY} L${toCenterX},${toTopY}`}
|
|
831
|
+
fill="none"
|
|
832
|
+
stroke={bestStyle.color}
|
|
833
|
+
strokeWidth={bestStyle.width}
|
|
834
|
+
strokeLinecap="round"
|
|
835
|
+
markerEnd={`url(#arrow-${bestStyle.color.replace('#', '')})`}
|
|
836
|
+
className={bestStyle.isActive ? 'edge-running' : ''}
|
|
837
|
+
/>
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
// 2. Each incoming edge uses same color to avoid inconsistency when overlapping
|
|
841
|
+
// Sort incoming edges by X coordinate for clearer paths
|
|
842
|
+
const sortedFromIds = [...fromIds].sort((a, b) => {
|
|
843
|
+
const ax = nodePositions[a]?.x || 0;
|
|
844
|
+
const bx = nodePositions[b]?.x || 0;
|
|
845
|
+
return ax - bx;
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
sortedFromIds.forEach((fromId) => {
|
|
849
|
+
const from = nodePositions[fromId];
|
|
850
|
+
if (!from) return;
|
|
851
|
+
const x1 = from.x + from.w / 2;
|
|
852
|
+
const y1 = from.y + from.h;
|
|
853
|
+
|
|
854
|
+
if (Math.abs(x1 - toCenterX) < 2) {
|
|
855
|
+
// Nearly vertically aligned, direct vertical line to merge point
|
|
856
|
+
paths.push(
|
|
857
|
+
<path
|
|
858
|
+
key={`edge-${fromId}-${targetId}`}
|
|
859
|
+
d={`M${x1},${y1} L${x1},${mergeY}`}
|
|
860
|
+
fill="none"
|
|
861
|
+
stroke={bestStyle.color}
|
|
862
|
+
strokeWidth={bestStyle.width}
|
|
863
|
+
strokeLinecap="round"
|
|
864
|
+
className={bestStyle.isActive ? 'edge-running' : ''}
|
|
865
|
+
/>
|
|
866
|
+
);
|
|
867
|
+
} else {
|
|
868
|
+
// From source bottom, first go down vertically, then smooth turn horizontally to merge point
|
|
869
|
+
const turnRadius = Math.min(15, Math.abs(x1 - toCenterX) / 2, (mergeY - y1) / 2);
|
|
870
|
+
const dir = toCenterX > x1 ? 1 : -1;
|
|
871
|
+
|
|
872
|
+
paths.push(
|
|
873
|
+
<path
|
|
874
|
+
key={`edge-${fromId}-${targetId}`}
|
|
875
|
+
d={`M${x1},${y1} L${x1},${mergeY - turnRadius} Q${x1},${mergeY} ${x1 + dir * turnRadius},${mergeY} L${toCenterX},${mergeY}`}
|
|
876
|
+
fill="none"
|
|
877
|
+
stroke={bestStyle.color}
|
|
878
|
+
strokeWidth={bestStyle.width}
|
|
879
|
+
strokeLinecap="round"
|
|
880
|
+
strokeLinejoin="round"
|
|
881
|
+
className={bestStyle.isActive ? 'edge-running' : ''}
|
|
882
|
+
/>
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
return paths;
|
|
890
|
+
};
|
|
891
|
+
|
|
892
|
+
return (
|
|
893
|
+
<div className="p-6 space-y-4">
|
|
894
|
+
{workflow.summary && (
|
|
895
|
+
<div className="text-sm text-[var(--muted)] bg-white/5 rounded-lg p-3">
|
|
896
|
+
💡 {workflow.summary}
|
|
897
|
+
</div>
|
|
898
|
+
)}
|
|
899
|
+
|
|
900
|
+
<div ref={containerRef} className="w-full overflow-x-auto">
|
|
901
|
+
<svg
|
|
902
|
+
ref={svgRef}
|
|
903
|
+
width={totalWidth}
|
|
904
|
+
height={totalHeight}
|
|
905
|
+
viewBox={`0 0 ${totalWidth} ${totalHeight}`}
|
|
906
|
+
className="select-none"
|
|
907
|
+
>
|
|
908
|
+
<defs>
|
|
909
|
+
{/* Arrow markers (by color) */}
|
|
910
|
+
{['4b5563', '22c55e', 'eab308', '3b82f6', 'ef4444'].map(hex => (
|
|
911
|
+
<marker key={hex} id={`arrow-${hex}`} viewBox="0 0 10 10" refX="9" refY="5"
|
|
912
|
+
markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
|
913
|
+
<path d="M1,1 L9,5 L1,9" fill="none" stroke={`#${hex}`} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
914
|
+
</marker>
|
|
915
|
+
))}
|
|
916
|
+
{/* Flow animation */}
|
|
917
|
+
<style>{`
|
|
918
|
+
@keyframes dash-flow { to { stroke-dashoffset: -20; } }
|
|
919
|
+
.edge-running { stroke-dasharray: 8 4; animation: dash-flow 0.8s linear infinite; }
|
|
920
|
+
`}</style>
|
|
921
|
+
</defs>
|
|
922
|
+
|
|
923
|
+
{/* Connections */}
|
|
924
|
+
{renderEdges()}
|
|
925
|
+
|
|
926
|
+
{/* Node cards (foreignObject embedded div) */}
|
|
927
|
+
{Object.values(nodePositions).map(({ x, y, w, h, node }) => (
|
|
928
|
+
<foreignObject key={node.id} x={x} y={y} width={w} height={h}>
|
|
929
|
+
<div
|
|
930
|
+
xmlns="http://www.w3.org/1999/xhtml"
|
|
931
|
+
className={`bg-[var(--background)] border ${statusColor[node.status]} rounded-xl p-3 h-full overflow-hidden transition-all cursor-default`}
|
|
932
|
+
style={{ fontSize: '12px' }}
|
|
933
|
+
onMouseEnter={(e) => {
|
|
934
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
935
|
+
setHoveredNode({ node, rect });
|
|
936
|
+
}}
|
|
937
|
+
onMouseLeave={() => setHoveredNode(null)}
|
|
938
|
+
>
|
|
939
|
+
{renderCardContent(node)}
|
|
940
|
+
</div>
|
|
941
|
+
</foreignObject>
|
|
942
|
+
))}
|
|
943
|
+
</svg>
|
|
944
|
+
</div>
|
|
945
|
+
|
|
946
|
+
{/* Hidden measurement container: render all cards to get actual heights */}
|
|
947
|
+
<div
|
|
948
|
+
ref={measureRef}
|
|
949
|
+
aria-hidden="true"
|
|
950
|
+
style={{
|
|
951
|
+
position: 'absolute',
|
|
952
|
+
visibility: 'hidden',
|
|
953
|
+
pointerEvents: 'none',
|
|
954
|
+
width: layout.nodeW,
|
|
955
|
+
left: -9999,
|
|
956
|
+
top: 0,
|
|
957
|
+
}}
|
|
958
|
+
>
|
|
959
|
+
{nodes.map(node => (
|
|
960
|
+
<div
|
|
961
|
+
key={node.id}
|
|
962
|
+
data-node-id={node.id}
|
|
963
|
+
className={`bg-[var(--background)] border rounded-xl p-3`}
|
|
964
|
+
style={{ fontSize: '12px', width: layout.nodeW }}
|
|
965
|
+
>
|
|
966
|
+
{renderCardContent(node)}
|
|
967
|
+
</div>
|
|
968
|
+
))}
|
|
969
|
+
</div>
|
|
970
|
+
|
|
971
|
+
{/* Hover Tooltip - floating layer showing full task content */}
|
|
972
|
+
{hoveredNode && (
|
|
973
|
+
<div
|
|
974
|
+
className="fixed z-[9999] pointer-events-none"
|
|
975
|
+
style={{
|
|
976
|
+
left: hoveredNode.rect.left + hoveredNode.rect.width / 2,
|
|
977
|
+
top: hoveredNode.rect.top - 8,
|
|
978
|
+
transform: 'translate(-50%, -100%)',
|
|
979
|
+
}}
|
|
980
|
+
>
|
|
981
|
+
<div className="bg-[#1a1a2e] border border-white/10 rounded-lg p-3 shadow-xl text-xs max-w-xs">
|
|
982
|
+
<div className="font-medium text-sm text-white mb-1">{hoveredNode.node.title}</div>
|
|
983
|
+
<div className="text-[var(--muted)] mb-1">👤 {hoveredNode.node.assigneeName}</div>
|
|
984
|
+
{hoveredNode.node.description && (
|
|
985
|
+
<p className="text-[var(--muted)] whitespace-pre-wrap break-words">{hoveredNode.node.description}</p>
|
|
986
|
+
)}
|
|
987
|
+
{hoveredNode.node.completedAt && hoveredNode.node.startedAt && (
|
|
988
|
+
<div className="mt-1 text-[var(--muted)]">{t('reqDetail.timeDuration', { n: Math.round((new Date(hoveredNode.node.completedAt) - new Date(hoveredNode.node.startedAt)) / 1000) })}</div>
|
|
989
|
+
)}
|
|
990
|
+
{(hoveredNode.node.status === 'running' || hoveredNode.node.status === 'reviewing' || hoveredNode.node.status === 'revision') && liveStatus?.currentNodeId === hoveredNode.node.id && liveStatus.currentAction && (
|
|
991
|
+
<div className={`mt-1 ${hoveredNode.node.status === 'reviewing' ? 'text-purple-300' : hoveredNode.node.status === 'revision' ? 'text-orange-300' : 'text-yellow-300'}`}>{hoveredNode.node.status === 'reviewing' ? '🔍' : hoveredNode.node.status === 'revision' ? '✏️' : '⚙️'} {liveStatus.currentAction}</div>
|
|
992
|
+
)}
|
|
993
|
+
{/* Arrow triangle */}
|
|
994
|
+
<div className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[6px] border-t-white/10" />
|
|
995
|
+
</div>
|
|
996
|
+
</div>
|
|
997
|
+
)}
|
|
998
|
+
</div>
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* 心流偷看弹窗 — 从需求详情群员列表点🧠触发
|
|
1005
|
+
* 三个 Tab:工作日志(flow)、内心独白(thoughts)、历史心流(history)
|
|
1006
|
+
*/
|
|
1007
|
+
function FlowPeekModal({ agentId, agentName, loading, tab, onTabChange, flowMsgs, monologueData, monologueThoughtMsgs, history, onClose }) {
|
|
1008
|
+
const { t } = useI18n();
|
|
1009
|
+
const cleanContent = (content) => {
|
|
1010
|
+
if (!content) return '';
|
|
1011
|
+
return content.replace(/^```[\s\S]*?```$/gm, t('reqDetail.flowPeek.codeBlock')).slice(0, 500);
|
|
1012
|
+
};
|
|
1013
|
+
|
|
1014
|
+
return (
|
|
1015
|
+
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={onClose}>
|
|
1016
|
+
<div className="w-full max-w-lg mx-4 bg-[var(--card)] border border-[var(--border)] rounded-2xl shadow-2xl max-h-[80vh] overflow-hidden flex flex-col" onClick={e => e.stopPropagation()}>
|
|
1017
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)] bg-purple-900/20">
|
|
1018
|
+
<div className="flex items-center gap-2">
|
|
1019
|
+
<span className="text-lg">🧠</span>
|
|
1020
|
+
<span className="font-medium text-sm">{t('reqDetail.flowPeek.title', { name: agentName })}</span>
|
|
1021
|
+
</div>
|
|
1022
|
+
<button onClick={onClose} className="text-[var(--muted)] hover:text-white transition-colors text-lg">✕</button>
|
|
1023
|
+
</div>
|
|
1024
|
+
|
|
1025
|
+
<div className="flex border-b border-[var(--border)] px-4 bg-[var(--card)]">
|
|
1026
|
+
{[
|
|
1027
|
+
{ id: 'thoughts', label: t('reqDetail.flowPeek.tabThoughts'), badge: monologueThoughtMsgs?.length || 0 },
|
|
1028
|
+
{ id: 'flow', label: t('reqDetail.flowPeek.tabFlow'), badge: flowMsgs?.length || 0 },
|
|
1029
|
+
{ id: 'history', label: t('reqDetail.flowPeek.tabHistory'), badge: history?.length || 0 },
|
|
1030
|
+
].map(tb => (
|
|
1031
|
+
<button
|
|
1032
|
+
key={tb.id}
|
|
1033
|
+
onClick={() => onTabChange(tb.id)}
|
|
1034
|
+
className={`px-3 py-2 text-xs font-medium transition-all border-b-2 ${
|
|
1035
|
+
tab === tb.id
|
|
1036
|
+
? 'border-purple-500 text-purple-300'
|
|
1037
|
+
: 'border-transparent text-[var(--muted)] hover:text-white'
|
|
1038
|
+
}`}
|
|
1039
|
+
>
|
|
1040
|
+
{tb.label}
|
|
1041
|
+
{tb.badge > 0 && (
|
|
1042
|
+
<span className="ml-1 text-[10px] bg-white/10 px-1.5 py-0.5 rounded-full">{tb.badge}</span>
|
|
1043
|
+
)}
|
|
1044
|
+
</button>
|
|
1045
|
+
))}
|
|
1046
|
+
</div>
|
|
1047
|
+
|
|
1048
|
+
<div className="flex-1 overflow-auto p-4 space-y-3">
|
|
1049
|
+
{loading ? (
|
|
1050
|
+
<div className="flex items-center justify-center py-8">
|
|
1051
|
+
<span className="animate-spin text-2xl">🧠</span>
|
|
1052
|
+
<span className="ml-2 text-sm text-[var(--muted)]">{t('reqDetail.flowPeek.loading')}</span>
|
|
1053
|
+
</div>
|
|
1054
|
+
) : tab === 'flow' ? (
|
|
1055
|
+
!flowMsgs?.length ? (
|
|
1056
|
+
<div className="text-center py-8 text-[var(--muted)] text-sm">
|
|
1057
|
+
<div className="text-3xl mb-2">📋</div>
|
|
1058
|
+
<p>{t('reqDetail.flowPeek.noFlowLogs')}</p>
|
|
1059
|
+
<p className="text-xs mt-1">{t('reqDetail.flowPeek.noFlowLogsHint')}</p>
|
|
1060
|
+
</div>
|
|
1061
|
+
) : (
|
|
1062
|
+
flowMsgs.map((msg, i) => (
|
|
1063
|
+
<div key={msg.id || i} className={`rounded-xl p-3 text-sm ${
|
|
1064
|
+
msg.type === 'tool_call'
|
|
1065
|
+
? 'bg-purple-900/20 border border-purple-500/10'
|
|
1066
|
+
: msg.type === 'output'
|
|
1067
|
+
? 'bg-green-900/20 border border-green-500/10'
|
|
1068
|
+
: 'bg-white/5 border border-white/10'
|
|
1069
|
+
}`}>
|
|
1070
|
+
<div className="flex items-center justify-between mb-1">
|
|
1071
|
+
<span className="text-xs text-[var(--muted)]">
|
|
1072
|
+
{msg.type === 'tool_call' ? '🔧' : msg.type === 'output' ? '📄' : '💬'}{' '}
|
|
1073
|
+
{msg.time ? new Date(msg.time).toLocaleTimeString() : ''}
|
|
1074
|
+
</span>
|
|
1075
|
+
</div>
|
|
1076
|
+
<div className="text-sm break-words">{cleanContent(msg.content)}</div>
|
|
1077
|
+
</div>
|
|
1078
|
+
))
|
|
1079
|
+
)
|
|
1080
|
+
) : tab === 'thoughts' ? (
|
|
1081
|
+
monologueThoughtMsgs?.length > 0 ? (
|
|
1082
|
+
<div className="space-y-3">
|
|
1083
|
+
{/* 如果当前正在思考,显示状态提示 */}
|
|
1084
|
+
{monologueData?.status === 'thinking' && (
|
|
1085
|
+
<div className="flex items-center gap-2 mb-2">
|
|
1086
|
+
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-500/20 text-purple-300 animate-pulse">
|
|
1087
|
+
{t('reqDetail.flowPeek.thinking')}
|
|
1088
|
+
</span>
|
|
1089
|
+
</div>
|
|
1090
|
+
)}
|
|
1091
|
+
{monologueThoughtMsgs.slice().reverse().map((msg, i) => (
|
|
1092
|
+
<div key={msg.id || i} className="bg-purple-900/20 border border-purple-500/10 rounded-xl p-3">
|
|
1093
|
+
<div className="flex items-center justify-between mb-1">
|
|
1094
|
+
<span className="text-xs text-purple-400">{t('systemSettings.monologue')}</span>
|
|
1095
|
+
<span className="text-xs text-[var(--muted)]">
|
|
1096
|
+
{msg.time ? new Date(msg.time).toLocaleTimeString() : ''}
|
|
1097
|
+
</span>
|
|
1098
|
+
</div>
|
|
1099
|
+
<div className="text-sm italic text-purple-200 whitespace-pre-wrap">
|
|
1100
|
+
{cleanContent(msg.content)}
|
|
1101
|
+
</div>
|
|
1102
|
+
</div>
|
|
1103
|
+
))}
|
|
1104
|
+
</div>
|
|
1105
|
+
) : (
|
|
1106
|
+
<div className="text-center py-8">
|
|
1107
|
+
<div className="text-3xl mb-2">😴</div>
|
|
1108
|
+
<p className="text-sm text-[var(--muted)]">{t('reqDetail.flowPeek.noMonologue')}</p>
|
|
1109
|
+
<p className="text-xs text-[var(--muted)] mt-1">{t('systemSettings.noMonologueYet')}</p>
|
|
1110
|
+
</div>
|
|
1111
|
+
)
|
|
1112
|
+
) : (
|
|
1113
|
+
!history?.length ? (
|
|
1114
|
+
<div className="text-center py-8 text-[var(--muted)] text-sm">{t('reqDetail.flowPeek.noHistory')}</div>
|
|
1115
|
+
) : (
|
|
1116
|
+
history.slice().reverse().map((m, i) => (
|
|
1117
|
+
<div key={m.id || i} className="bg-white/5 rounded-xl p-3 space-y-2">
|
|
1118
|
+
<div className="flex items-center justify-between">
|
|
1119
|
+
<span className="text-xs text-[var(--muted)]">
|
|
1120
|
+
{m.startedAt ? new Date(m.startedAt).toLocaleString() : ''}
|
|
1121
|
+
</span>
|
|
1122
|
+
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
|
1123
|
+
m.decision === 'spoke' ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'
|
|
1124
|
+
}`}>
|
|
1125
|
+
{m.decision === 'spoke' ? t('reqDetail.flowPeek.spoke') : t('reqDetail.flowPeek.keptSilent')}
|
|
1126
|
+
</span>
|
|
1127
|
+
</div>
|
|
1128
|
+
{m.thoughts?.map((thought, ti) => (
|
|
1129
|
+
<div key={thought.id || ti} className="text-sm text-[var(--muted)] bg-black/20 rounded-lg p-2">
|
|
1130
|
+
{thought.content?.startsWith('[')
|
|
1131
|
+
? <span className="text-green-400">{thought.content}</span>
|
|
1132
|
+
: <span className="italic">{thought.content}</span>
|
|
1133
|
+
}
|
|
1134
|
+
</div>
|
|
1135
|
+
))}
|
|
1136
|
+
</div>
|
|
1137
|
+
))
|
|
1138
|
+
)
|
|
1139
|
+
)}
|
|
1140
|
+
</div>
|
|
1141
|
+
</div>
|
|
1142
|
+
</div>
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* 群员列表 + 流程卡点面板
|
|
1149
|
+
* 展示参与此需求的所有 agent,以及当前流程卡在谁身上
|
|
1150
|
+
*/
|
|
1151
|
+
function MembersAndBlockingPanel({ members, blockingInfo, workflow, status, onPeekFlow, onViewAgent }) {
|
|
1152
|
+
const { t } = useI18n();
|
|
1153
|
+
if (!members?.length && !blockingInfo?.length) return null;
|
|
1154
|
+
|
|
1155
|
+
const nodeStatusConfig = {
|
|
1156
|
+
running: { label: t('reqDetail.members.running'), color: 'text-yellow-400', bg: 'bg-yellow-900/20', icon: '⚡' },
|
|
1157
|
+
reviewing: { label: t('reqDetail.members.reviewing'), color: 'text-blue-400', bg: 'bg-blue-900/20', icon: '🔍' },
|
|
1158
|
+
revision: { label: t('reqDetail.members.revision'), color: 'text-orange-400', bg: 'bg-orange-900/20', icon: '🔄' },
|
|
1159
|
+
waiting: { label: t('reqDetail.members.waiting'), color: 'text-gray-400', bg: 'bg-gray-900/20', icon: '⏳' },
|
|
1160
|
+
ready: { label: t('reqDetail.members.ready'), color: 'text-cyan-400', bg: 'bg-cyan-900/20', icon: '🟢' },
|
|
1161
|
+
completed: { label: t('reqDetail.members.completed'), color: 'text-green-400', bg: 'bg-green-900/20', icon: '✅' },
|
|
1162
|
+
failed: { label: t('reqDetail.members.failed'), color: 'text-red-400', bg: 'bg-red-900/20', icon: '❌' },
|
|
1163
|
+
};
|
|
1164
|
+
|
|
1165
|
+
// 统计每个 agent 在此需求中的任务状态
|
|
1166
|
+
const agentTaskMap = {};
|
|
1167
|
+
if (workflow?.nodes) {
|
|
1168
|
+
for (const node of workflow.nodes) {
|
|
1169
|
+
if (node.assigneeId) {
|
|
1170
|
+
if (!agentTaskMap[node.assigneeId]) agentTaskMap[node.assigneeId] = [];
|
|
1171
|
+
agentTaskMap[node.assigneeId].push({ title: node.title, status: node.status });
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
return (
|
|
1177
|
+
<div className="px-6 py-3 border-b border-white/[0.06] bg-[var(--card)]">
|
|
1178
|
+
{/* 流程卡点 */}
|
|
1179
|
+
{blockingInfo?.length > 0 && status === 'in_progress' && (
|
|
1180
|
+
<div className="mb-3">
|
|
1181
|
+
<div className="text-xs text-[var(--muted)] mb-1.5 font-medium">{t('reqDetail.members.blockingTitle')}</div>
|
|
1182
|
+
<div className="flex flex-wrap gap-2">
|
|
1183
|
+
{blockingInfo.map((b, i) => {
|
|
1184
|
+
const st = nodeStatusConfig[b.status] || nodeStatusConfig.running;
|
|
1185
|
+
return (
|
|
1186
|
+
<div key={b.nodeId || i} className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border ${st.bg} border-white/10`}>
|
|
1187
|
+
<span className="text-sm">{st.icon}</span>
|
|
1188
|
+
<div>
|
|
1189
|
+
<div className="text-xs font-medium">{b.nodeTitle}</div>
|
|
1190
|
+
<div className="text-[10px] text-[var(--muted)]">
|
|
1191
|
+
👤 {b.assigneeName}
|
|
1192
|
+
{b.status === 'reviewing' && b.reviewerName && (
|
|
1193
|
+
<span className="ml-1">→ 🔍 {b.reviewerName}</span>
|
|
1194
|
+
)}
|
|
1195
|
+
<span className={`ml-1 ${st.color}`}>{st.label}</span>
|
|
1196
|
+
</div>
|
|
1197
|
+
</div>
|
|
1198
|
+
</div>
|
|
1199
|
+
);
|
|
1200
|
+
})}
|
|
1201
|
+
</div>
|
|
1202
|
+
</div>
|
|
1203
|
+
)}
|
|
1204
|
+
|
|
1205
|
+
{/* 群员列表 */}
|
|
1206
|
+
{members?.length > 0 && (
|
|
1207
|
+
<div>
|
|
1208
|
+
<div className="text-xs text-[var(--muted)] mb-1.5 font-medium">{t('reqDetail.members.title')} {t('reqDetail.members.count', { n: members.length })}</div>
|
|
1209
|
+
<div className="flex flex-wrap gap-2">
|
|
1210
|
+
{members.map(m => {
|
|
1211
|
+
const tasks = agentTaskMap[m.id] || [];
|
|
1212
|
+
const activeTasks = tasks.filter(t => ['running', 'reviewing', 'revision'].includes(t.status));
|
|
1213
|
+
const isActive = activeTasks.length > 0;
|
|
1214
|
+
const completedCount = tasks.filter(t => t.status === 'completed').length;
|
|
1215
|
+
return (
|
|
1216
|
+
<div
|
|
1217
|
+
key={m.id}
|
|
1218
|
+
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-lg border transition-colors ${
|
|
1219
|
+
isActive
|
|
1220
|
+
? 'bg-yellow-900/10 border-yellow-500/20'
|
|
1221
|
+
: 'bg-white/[0.02] border-white/[0.06] hover:bg-white/[0.04]'
|
|
1222
|
+
}`}
|
|
1223
|
+
title={tasks.length > 0 ? t('reqDetail.members.taskTooltip', { tasks: tasks.map(task => `${task.title}(${task.status})`).join(', ') }) : t('reqDetail.members.noTask')}
|
|
1224
|
+
>
|
|
1225
|
+
{/* 头像区域:上方🧠偷看心流,点击头像看卡片 */}
|
|
1226
|
+
<div className="relative group/avatar flex flex-col items-center">
|
|
1227
|
+
{/* 🧠 偷看心流按钮 — 悬浮头像时显示在上方 */}
|
|
1228
|
+
{onPeekFlow && (
|
|
1229
|
+
<button
|
|
1230
|
+
onClick={(e) => { e.stopPropagation(); onPeekFlow(m.id); }}
|
|
1231
|
+
className="absolute -top-3 left-1/2 -translate-x-1/2 opacity-0 group-hover/avatar:opacity-100 transition-all duration-200 text-[11px] hover:scale-125 z-10"
|
|
1232
|
+
title={t('reqDetail.members.peekFlow')}
|
|
1233
|
+
>
|
|
1234
|
+
🧠
|
|
1235
|
+
</button>
|
|
1236
|
+
)}
|
|
1237
|
+
{/* 头像 — 点击看卡片 */}
|
|
1238
|
+
<button
|
|
1239
|
+
onClick={(e) => { e.stopPropagation(); onViewAgent?.(m.id); }}
|
|
1240
|
+
className="focus:outline-none hover:ring-2 hover:ring-purple-500/50 rounded-full transition-all"
|
|
1241
|
+
title={t('reqDetail.members.viewProfile')}
|
|
1242
|
+
>
|
|
1243
|
+
{m.avatar ? (
|
|
1244
|
+
<CachedAvatar src={m.avatar} alt="" className="w-7 h-7 rounded-full cursor-pointer" />
|
|
1245
|
+
) : (
|
|
1246
|
+
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-indigo-600 to-blue-700 flex items-center justify-center text-[10px] cursor-pointer">
|
|
1247
|
+
{m.name?.charAt(0) || '?'}
|
|
1248
|
+
</div>
|
|
1249
|
+
)}
|
|
1250
|
+
</button>
|
|
1251
|
+
</div>
|
|
1252
|
+
<div className="min-w-0">
|
|
1253
|
+
<div className="text-xs font-medium truncate">{m.name}</div>
|
|
1254
|
+
<div className="text-[10px] text-[var(--muted)] truncate">
|
|
1255
|
+
{m.role}
|
|
1256
|
+
{isActive && (
|
|
1257
|
+
<span className="ml-1 text-yellow-400 animate-pulse">{t('reqDetail.members.working')}</span>
|
|
1258
|
+
)}
|
|
1259
|
+
{!isActive && completedCount > 0 && (
|
|
1260
|
+
<span className="ml-1 text-green-400">✅{completedCount}</span>
|
|
1261
|
+
)}
|
|
1262
|
+
{tasks.length === 0 && (
|
|
1263
|
+
<span className="ml-1 text-gray-500">{t('reqDetail.members.noTask')}</span>
|
|
1264
|
+
)}
|
|
1265
|
+
</div>
|
|
1266
|
+
</div>
|
|
1267
|
+
</div>
|
|
1268
|
+
);
|
|
1269
|
+
})}
|
|
1270
|
+
</div>
|
|
1271
|
+
</div>
|
|
1272
|
+
)}
|
|
1273
|
+
</div>
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
|