-
-
- 正在打开工作台
+
+ {messages.length === 0 ? (
+
+ ) : (
+
+
-
- ) : (
- <>
-
- {messages.length === 0 ? (
-
- ) : (
-
-
-
- )}
-
+ )}
+
-
-
- {sendFailure && (
-
- )}
-
-
-
- >
- )}
+
+
+ {sendFailure && (
+
+ )}
+
+
+
);
diff --git a/src/components/chat-composer.tsx b/src/components/chat-composer.tsx
index 1e8bf87..eba01b3 100644
--- a/src/components/chat-composer.tsx
+++ b/src/components/chat-composer.tsx
@@ -32,6 +32,8 @@ export function ChatComposer({
skills,
selectedIds,
resolvedSkillIds,
+ activeSkillId,
+ completedSkillIds,
onSelectedChange,
onOpenSkillPicker,
isStreaming,
@@ -40,6 +42,8 @@ export function ChatComposer({
skills: Skill[];
selectedIds: string[];
resolvedSkillIds: string[] | null;
+ activeSkillId: string | null;
+ completedSkillIds: string[];
onSelectedChange: (ids: string[]) => void;
onOpenSkillPicker: () => void;
isStreaming: boolean;
@@ -202,9 +206,13 @@ export function ChatComposer({
? "selected"
: !routeResolved
? "checking"
- : resolvedSkillIds.includes(skill.id)
- ? "used"
- : "unused";
+ : !resolvedSkillIds.includes(skill.id)
+ ? "unused"
+ : activeSkillId === skill.id
+ ? "running"
+ : completedSkillIds.includes(skill.id)
+ ? "completed"
+ : "queued";
return (
{index > 0 && (
@@ -216,8 +224,10 @@ export function ChatComposer({
!routeResolved &&
"animate-pulse text-amber-500/70",
routeResolved &&
- resolvedSkillIds?.includes(skill.id) &&
+ completedSkillIds.includes(skill.id) &&
"text-emerald-500/80",
+ activeSkillId === skill.id &&
+ "animate-pulse text-blue-500/90",
)}
/>
)}
diff --git a/src/components/chat-workspace.tsx b/src/components/chat-workspace.tsx
index dadac63..0aebf41 100644
--- a/src/components/chat-workspace.tsx
+++ b/src/components/chat-workspace.tsx
@@ -11,6 +11,7 @@ import type {
ChatMessage,
ChatRun,
Conversation,
+ ConversationContinuation,
Skill,
SseEvent,
} from "@/lib/types";
@@ -33,22 +34,71 @@ function wait(ms: number, signal: AbortSignal) {
});
}
-export function ChatWorkspace() {
- const [skills, setSkills] = useState([]);
- const [messages, setMessages] = useState([]);
- const [conversation, setConversation] = useState(null);
- const [selectedIds, setSelectedIds] = useState([]);
+type ChatWorkspaceInitialData = {
+ skills: Skill[];
+ messages: ChatMessage[];
+ conversation: Conversation;
+ activeRun: ChatRun | null;
+ continuation: ConversationContinuation;
+ apiConfigured: boolean;
+};
+
+export function ChatWorkspace({
+ initialData,
+}: {
+ initialData: ChatWorkspaceInitialData;
+}) {
+ const [skills, setSkills] = useState(initialData.skills);
+ const [messages, setMessages] = useState(() =>
+ initialData.activeRun
+ ? initialData.messages.map((message) =>
+ message.id === initialData.activeRun?.assistantMessageId
+ ? {
+ ...message,
+ content: "",
+ usedSkillIds: [],
+ skillExecutions: [],
+ status: "streaming" as const,
+ }
+ : message,
+ )
+ : initialData.messages,
+ );
+ const [conversation, setConversation] = useState(
+ initialData.conversation,
+ );
+ const [selectedIds, setSelectedIds] = useState(() => {
+ const activeMessage = initialData.activeRun
+ ? initialData.messages.find(
+ (message) =>
+ message.id === initialData.activeRun?.assistantMessageId,
+ )
+ : null;
+ const selected = activeMessage
+ ? activeMessage.selectedSkillIds
+ : initialData.continuation.skillIds;
+ return selected.filter((id) =>
+ initialData.skills.some((skill) => skill.id === id),
+ );
+ });
const [pickerOpen, setPickerOpen] = useState(false);
- const [loading, setLoading] = useState(true);
- const [streaming, setStreaming] = useState(false);
- const [generationStatus, setGenerationStatus] = useState("");
- const [apiConfigured, setApiConfigured] = useState(false);
- const [activeRun, setActiveRun] = useState(null);
+ const [streaming, setStreaming] = useState(
+ Boolean(initialData.activeRun),
+ );
+ const [generationStatus, setGenerationStatus] = useState(
+ initialData.activeRun ? "正在恢复本轮回答" : "",
+ );
+ const [apiConfigured] = useState(initialData.apiConfigured);
+ const [activeRun, setActiveRun] = useState(
+ initialData.activeRun,
+ );
const [stopping, setStopping] = useState(false);
const [resolvedSkillIds, setResolvedSkillIds] = useState(
null,
);
- const activeRunRef = useRef(null);
+ const [activeSkillId, setActiveSkillId] = useState(null);
+ const [completedSkillIds, setCompletedSkillIds] = useState([]);
+ const activeRunRef = useRef(initialData.activeRun);
const stopRequestedRef = useRef(false);
const visibleAssistantContentRef = useRef("");
const [sendFailure, setSendFailure] = useState<{
@@ -67,84 +117,6 @@ export function ChatWorkspace() {
);
}, []);
- useEffect(() => {
- let cancelled = false;
-
- async function load() {
- try {
- const [skillsResponse, chatResponse] = await Promise.all([
- fetch("/api/skills", { cache: "no-store" }),
- fetch("/api/chat", { cache: "no-store" }),
- ]);
- if (!skillsResponse.ok || !chatResponse.ok) {
- throw new Error("工作台加载失败");
- }
- const skillPayload = (await skillsResponse.json()) as {
- skills: Skill[];
- };
- const chatPayload = (await chatResponse.json()) as {
- conversation: Conversation;
- messages: ChatMessage[];
- activeRun: ChatRun | null;
- retainedSkillIds: string[];
- apiConfigured: boolean;
- };
- if (cancelled) return;
-
- const hydratedMessages = chatPayload.activeRun
- ? chatPayload.messages.map((message) =>
- message.id === chatPayload.activeRun?.assistantMessageId
- ? {
- ...message,
- content: "",
- usedSkillIds: [],
- status: "streaming" as const,
- }
- : message,
- )
- : chatPayload.messages;
-
- setSkills(skillPayload.skills);
- setConversation(chatPayload.conversation);
- setMessages(hydratedMessages);
- if (chatPayload.activeRun) {
- const activeMessage = chatPayload.messages.find(
- (message) =>
- message.id === chatPayload.activeRun?.assistantMessageId,
- );
- setSelectedIds(
- (activeMessage?.selectedSkillIds ?? []).filter((id) =>
- skillPayload.skills.some((skill) => skill.id === id),
- ),
- );
- setResolvedSkillIds(null);
- } else {
- setSelectedIds(
- (chatPayload.retainedSkillIds ?? []).filter((id) =>
- skillPayload.skills.some((skill) => skill.id === id),
- ),
- );
- }
- setApiConfigured(chatPayload.apiConfigured);
- activeRunRef.current = chatPayload.activeRun;
- setActiveRun(chatPayload.activeRun);
- setStreaming(Boolean(chatPayload.activeRun));
- if (chatPayload.activeRun) setGenerationStatus("正在恢复本轮回答");
- } catch (error) {
- if (!cancelled) {
- toast.error(error instanceof Error ? error.message : "工作台加载失败");
- }
- } finally {
- if (!cancelled) setLoading(false);
- }
- }
-
- void load();
- return () => {
- cancelled = true;
- };
- }, []);
-
useEffect(() => {
const refresh = () => void loadSkills().catch(() => undefined);
window.addEventListener("focus", refresh);
@@ -201,6 +173,55 @@ export function ChatWorkspace() {
usedSkillIds: event.skillIds,
}));
}
+ if (event.type === "skill_started") {
+ setActiveSkillId(event.skillId);
+ updateAssistant((message) => ({
+ ...message,
+ skillExecutions: [
+ ...message.skillExecutions.filter(
+ (item) =>
+ item.skillId !== event.skillId ||
+ item.position !== event.position,
+ ),
+ {
+ skillId: event.skillId,
+ skillName: event.skillName,
+ position: event.position,
+ total: event.total,
+ status: "running" as const,
+ input: event.input,
+ startedAt: event.startedAt,
+ },
+ ].sort((a, b) => a.position - b.position),
+ }));
+ }
+ if (event.type === "skill_completed") {
+ setCompletedSkillIds((ids) =>
+ ids.includes(event.skillId) ? ids : [...ids, event.skillId],
+ );
+ setActiveSkillId((id) => (id === event.skillId ? null : id));
+ updateAssistant((message) => ({
+ ...message,
+ skillExecutions: message.skillExecutions
+ .map((item) =>
+ item.skillId === event.skillId &&
+ item.position === event.position
+ ? {
+ skillId: event.skillId,
+ skillName: event.skillName,
+ position: event.position,
+ total: event.total,
+ status: "completed" as const,
+ input: event.input,
+ result: event.result,
+ startedAt: event.startedAt,
+ completedAt: event.completedAt,
+ }
+ : item,
+ )
+ .sort((a, b) => a.position - b.position),
+ }));
+ }
if (event.type === "skill_retention") {
retainedForNextTurn = event.skillIds;
setSelectedIds(event.skillIds);
@@ -294,6 +315,8 @@ export function ChatWorkspace() {
setGenerationStatus("");
setSelectedIds(retainedForNextTurn);
setResolvedSkillIds(null);
+ setActiveSkillId(null);
+ setCompletedSkillIds([]);
setActiveRun(null);
}
}
@@ -310,6 +333,8 @@ export function ChatWorkspace() {
stopRequestedRef.current = false;
visibleAssistantContentRef.current = "";
setStopping(false);
+ setActiveSkillId(null);
+ setCompletedSkillIds([]);
const selectedForTurn = [...(selectedOverride ?? selectedIds)];
const userLocalId = `local_user_${crypto.randomUUID()}`;
const assistantLocalId = `local_assistant_${crypto.randomUUID()}`;
@@ -321,6 +346,8 @@ export function ChatWorkspace() {
content,
selectedSkillIds: selectedForTurn,
usedSkillIds: [],
+ skillSnapshots: [],
+ skillExecutions: [],
status: "complete",
createdAt: now,
};
@@ -330,6 +357,8 @@ export function ChatWorkspace() {
content: "",
selectedSkillIds: selectedForTurn,
usedSkillIds: [],
+ skillSnapshots: [],
+ skillExecutions: [],
status: "streaming",
createdAt: new Date(Date.now() + 1).toISOString(),
};
@@ -491,6 +520,8 @@ export function ChatWorkspace() {
setMessages([]);
setSelectedIds([]);
setResolvedSkillIds(null);
+ setActiveSkillId(null);
+ setCompletedSkillIds([]);
setSendFailure(null);
stopRequestedRef.current = false;
activeRunRef.current = null;
@@ -508,15 +539,20 @@ export function ChatWorkspace() {
messages={messages}
selectedIds={selectedIds}
resolvedSkillIds={resolvedSkillIds}
- loading={loading}
+ activeSkillId={activeSkillId}
+ completedSkillIds={completedSkillIds}
streaming={streaming}
stopping={stopping}
generationStatus={generationStatus}
sendFailure={sendFailure}
- canSend={Boolean(conversation) && !loading}
+ canSend={Boolean(conversation)}
onSelectedChange={(ids) => {
setSelectedIds(ids);
- if (!streaming) setResolvedSkillIds(null);
+ if (!streaming) {
+ setResolvedSkillIds(null);
+ setActiveSkillId(null);
+ setCompletedSkillIds([]);
+ }
}}
onOpenSkillPicker={() => setPickerOpen(true)}
onSend={sendMessage}
@@ -539,6 +575,8 @@ export function ChatWorkspace() {
onConfirm={(ids) => {
setSelectedIds(ids);
setResolvedSkillIds(null);
+ setActiveSkillId(null);
+ setCompletedSkillIds([]);
}}
onSkillsChange={setSkills}
/>
diff --git a/src/components/skill-builder.tsx b/src/components/skill-builder.tsx
index fe3f5eb..5025695 100644
--- a/src/components/skill-builder.tsx
+++ b/src/components/skill-builder.tsx
@@ -16,6 +16,7 @@ import { Button } from "@/components/ui/button";
import { consumeSse } from "@/lib/client-sse";
import type {
BuilderMessage,
+ BuilderProposal,
BuilderSuggestionSource,
Skill,
SkillNode,
@@ -58,6 +59,8 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
const [description, setDescription] = useState("");
const [nodes, setNodes] = useState(createEmptyNodes);
const [messages, setMessages] = useState([]);
+ const [pendingProposal, setPendingProposal] =
+ useState(null);
const [loading, setLoading] = useState(Boolean(skillId));
const [streaming, setStreaming] = useState(false);
const [saving, setSaving] = useState(false);
@@ -164,7 +167,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
setMessages([
createBuilderMessage(
"assistant",
- `已载入「${skillPayload.skill.name}」。直接告诉我想补充或修改什么,我会判断应该更新哪些节点。`,
+ `已载入「${skillPayload.skill.name}」。直接告诉我想补充或修改什么,我会先整理成提案,等你确认后再更新节点。`,
"constraints",
SKILL_NODE_DEFINITIONS.map((node) => node.key),
),
@@ -248,6 +251,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
message: content,
skillName: name,
skillDescription: description,
+ pendingProposal,
nodes,
messages: previousMessages.map((message) => ({
role: message.role,
@@ -262,9 +266,12 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
}
if (event.type === "status") setStatus(event.label);
if (event.type === "builder_update") {
- setName(event.evaluation.skillName);
- setDescription(event.evaluation.skillDescription);
- setNodes(event.evaluation.nodes);
+ setPendingProposal(event.evaluation.proposal);
+ if (event.evaluation.action === "applied") {
+ setName(event.evaluation.skillName);
+ setDescription(event.evaluation.skillDescription);
+ setNodes(event.evaluation.nodes);
+ }
const suggestionsMatchNode =
event.evaluation.suggestionNodeKey ===
event.evaluation.activeNode;
@@ -292,16 +299,15 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
? {
...message,
nodeKey:
+ event.evaluation.proposedNodeKeys[0] ??
event.evaluation.updatedNodeKeys[0] ??
event.evaluation.activeNode,
- updatedNodeKeys: event.evaluation.updatedNodeKeys,
+ updatedNodeKeys:
+ event.evaluation.action === "proposed"
+ ? event.evaluation.proposedNodeKeys
+ : event.evaluation.updatedNodeKeys,
}
: message,
- )
- .filter(
- (message) =>
- event.evaluation.activeNode === activeNode ||
- message.id === assistantMessage.id,
),
);
}
@@ -394,7 +400,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
}
async function save() {
- if (!allComplete || saving) return;
+ if (!allComplete || saving || pendingProposal) return;
setSaving(true);
try {
const response = await fetch(
@@ -462,8 +468,14 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
size="sm"
className="bg-[#07C160] text-white hover:bg-[#06AD56] disabled:bg-muted-foreground/35 lg:hidden"
onClick={() => void save()}
- disabled={!allComplete || saving}
- title={!allComplete ? "完成全部节点后才能保存" : undefined}
+ disabled={!allComplete || saving || Boolean(pendingProposal)}
+ title={
+ pendingProposal
+ ? "请先确认或放弃当前提案"
+ : !allComplete
+ ? "完成全部节点后才能保存"
+ : undefined
+ }
>
{saving ? (
@@ -526,6 +538,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
generationStatus={status}
suggestions={allComplete ? [] : suggestions}
suggestionSource={suggestionSource}
+ pendingProposal={pendingProposal}
sendFailure={sendFailure}
onSend={sendBuilderMessage}
onStop={stop}
@@ -573,7 +586,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
- 五个节点已完成。仍可继续描述修改内容,由 AI 自动更新对应节点。
+ 五个节点已完成。仍可继续描述修改内容,AI 会先给出提案,确认后再更新。
) : null}
@@ -590,7 +603,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {