返回 DeepSeek-Reasonix
composerProfile.ts
根目录 / desktop / frontend / src / lib / composerProfile.ts
1 import {
2 modeFromAxes,
3 modeHasAutoApproveTools,
4 modeHasPlan,
5 normalizeCollaborationMode,
6 normalizeMode,
7 normalizeToolApprovalMode,
8 type CollaborationMode,
9 type GoalStatus,
10 type GoalLifecycleView,
11 type Meta,
12 type Mode,
13 type TabMeta,
14 type QualityFloor,
15 type ToolApprovalMode,
16 } from "./types";
17
18 export type ComposerProfileField = "collaborationMode" | "toolApprovalMode" | "goal" | "qualityFloor";
19
20 export type ComposerProfilePending = Partial<Record<ComposerProfileField, true>>;
21
22 export interface ComposerProfile {
23 collaborationMode: CollaborationMode;
24 goalDraftMode: boolean;
25 toolApprovalMode: ToolApprovalMode;
26 goal: string;
27 qualityFloor: QualityFloor;
28 pending: ComposerProfilePending;
29 }
30
31 export type ComposerProfilesByTab = Record<string, ComposerProfile>;
32 export type UserPlanModeIntents = Record<string, true>;
33
34 const profileFields: ComposerProfileField[] = ["collaborationMode", "toolApprovalMode", "goal", "qualityFloor"];
35
36 export const defaultComposerProfile: ComposerProfile = Object.freeze({
37 collaborationMode: "normal",
38 goalDraftMode: false,
39 toolApprovalMode: "workspace-write",
40 goal: "",
41 qualityFloor: "standard",
42 pending: {},
43 });
44
45 function activeGoal(goal?: string, status?: GoalStatus, view?: GoalLifecycleView): string {
46 const trimmed = (view?.objective ?? goal ?? "").trim();
47 if (!trimmed) return "";
48 if (view) return view.phase === "complete" ? "" : trimmed;
49 if (status && status !== "running") return "";
50 return trimmed;
51 }
52
53 function profileWithPending(profile: Omit<ComposerProfile, "pending">, pending: ComposerProfilePending = {}): ComposerProfile {
54 return { ...profile, pending };
55 }
56
57 function fallbackToolApprovalMode(rawMode: string | undefined, fallback?: ToolApprovalMode | null): ToolApprovalMode | undefined {
58 if ((rawMode ?? "").trim() !== "") return undefined;
59 return fallback ? normalizeToolApprovalMode(fallback) : undefined;
60 }
61
62 export function composerProfileFromTab(tab?: TabMeta | null, fallback?: ToolApprovalMode | null): ComposerProfile {
63 if (!tab) return { ...defaultComposerProfile, pending: {} };
64 const legacyMode = normalizeMode(tab.mode);
65 const goal = activeGoal(tab.goal, tab.goalStatus, tab.goalView);
66 return profileWithPending({
67 collaborationMode: normalizeCollaborationMode(tab.collaborationMode, goal, legacyMode),
68 goalDraftMode: false,
69 toolApprovalMode: normalizeToolApprovalMode(
70 tab.toolApprovalMode,
71 legacyMode,
72 false,
73 fallbackToolApprovalMode(tab.toolApprovalMode, fallback),
74 ),
75 goal,
76 qualityFloor: tab.qualityFloor ?? "standard",
77 });
78 }
79
80 export function composerProfileFromMeta(meta?: Meta | null, legacyMode?: Mode, fallback?: ToolApprovalMode | null): ComposerProfile {
81 if (!meta) return { ...defaultComposerProfile, pending: {} };
82 const fallbackMode = normalizeMode(legacyMode);
83 const goal = activeGoal(meta.goal, meta.goalStatus, meta.goalView);
84 const toolApprovalMode = normalizeToolApprovalMode(
85 meta.toolApprovalMode,
86 fallbackMode,
87 meta.autoApproveTools ?? meta.bypass,
88 fallbackToolApprovalMode(meta.toolApprovalMode, fallback),
89 );
90 return profileWithPending({
91 collaborationMode: normalizeCollaborationMode(meta.collaborationMode, goal, fallbackMode),
92 goalDraftMode: false,
93 toolApprovalMode,
94 goal,
95 qualityFloor: meta.qualityFloor ?? "standard",
96 });
97 }
98
99 function fieldValue(profile: ComposerProfile, field: ComposerProfileField): string {
100 return profile[field];
101 }
102
103 function assignField(profile: ComposerProfile, field: ComposerProfileField, value: string) {
104 switch (field) {
105 case "collaborationMode":
106 profile.collaborationMode = value as CollaborationMode;
107 return;
108 case "toolApprovalMode":
109 profile.toolApprovalMode = value as ToolApprovalMode;
110 return;
111 case "goal":
112 profile.goal = value;
113 return;
114 case "qualityFloor":
115 profile.qualityFloor = value as QualityFloor;
116 return;
117 }
118 }
119
120 function profilesEqual(a: ComposerProfile | undefined, b: ComposerProfile | undefined): boolean {
121 if (!a || !b) return a === b;
122 return a.collaborationMode === b.collaborationMode
123 && a.goalDraftMode === b.goalDraftMode
124 && a.toolApprovalMode === b.toolApprovalMode
125 && a.goal === b.goal
126 && a.qualityFloor === b.qualityFloor
127 && profileFields.every((field) => Boolean(a.pending[field]) === Boolean(b.pending[field]));
128 }
129
130 export function reconcileComposerProfile(current: ComposerProfile | undefined, backend: ComposerProfile): ComposerProfile {
131 if (!current) return { ...backend, pending: {} };
132
133 const pending: ComposerProfilePending = {};
134 const next: ComposerProfile = { ...backend, pending };
135
136 for (const field of profileFields) {
137 if (!current.pending[field]) continue;
138 if (fieldValue(current, field) === fieldValue(backend, field)) continue;
139 pending[field] = true;
140 assignField(next, field, fieldValue(current, field));
141 }
142
143 if (current.goalDraftMode && !backend.goal && !next.goal) {
144 next.goalDraftMode = true;
145 }
146 if (next.goal) {
147 next.goalDraftMode = false;
148 }
149
150 return next;
151 }
152
153 export function hydrateComposerProfilesFromTabs(current: ComposerProfilesByTab, tabs: TabMeta[]): ComposerProfilesByTab {
154 const next: ComposerProfilesByTab = {};
155 let changed = false;
156
157 for (const tab of tabs) {
158 const profile = reconcileComposerProfile(current[tab.id], composerProfileFromTab(tab, current[tab.id]?.toolApprovalMode));
159 next[tab.id] = profile;
160 if (!profilesEqual(current[tab.id], profile)) changed = true;
161 }
162
163 for (const id of Object.keys(current)) {
164 if (!next[id]) changed = true;
165 }
166
167 return changed ? next : current;
168 }
169
170 export function hydrateComposerProfileFromMeta(current: ComposerProfilesByTab, tabId: string, meta: Meta): ComposerProfilesByTab {
171 const previous = current[tabId];
172 const backend = composerProfileFromMeta(
173 meta,
174 previous ? composerProfileMode(previous) : undefined,
175 previous?.toolApprovalMode,
176 );
177 const profile = reconcileComposerProfile(previous, backend);
178 if (profilesEqual(previous, profile)) return current;
179 return { ...current, [tabId]: profile };
180 }
181
182 export function patchComposerProfile(
183 current: ComposerProfilesByTab,
184 tabId: string,
185 base: ComposerProfile | undefined,
186 patch: Partial<Omit<ComposerProfile, "pending">>,
187 pendingFields: ComposerProfileField[],
188 ): ComposerProfilesByTab {
189 const previous = current[tabId] ?? base ?? defaultComposerProfile;
190 const pending: ComposerProfilePending = { ...previous.pending };
191 for (const field of pendingFields) pending[field] = true;
192 const profile: ComposerProfile = {
193 ...previous,
194 ...patch,
195 pending,
196 };
197 if (profile.goal) {
198 profile.goalDraftMode = false;
199 }
200 if (profilesEqual(previous, profile)) return current;
201 return { ...current, [tabId]: profile };
202 }
203
204 export function composerProfileMode(profile: ComposerProfile): Mode {
205 return modeFromAxes(profile.collaborationMode === "plan", normalizeToolApprovalMode(profile.toolApprovalMode) === "danger-full-access");
206 }
207
208 export function displayedComposerProfileCollaborationMode(profile: ComposerProfile): CollaborationMode {
209 if (profile.goalDraftMode) return "goal";
210 return profile.collaborationMode;
211 }
212
213 export function controllerComposerProfileCollaborationMode(profile: ComposerProfile): CollaborationMode {
214 const displayed = displayedComposerProfileCollaborationMode(profile);
215 return displayed === "goal" && !profile.goal ? "normal" : displayed;
216 }
217
218 export function composerProfileWithMode(mode: Mode): Partial<Omit<ComposerProfile, "pending">> {
219 return {
220 collaborationMode: modeHasPlan(mode) ? "plan" : "normal",
221 goalDraftMode: false,
222 toolApprovalMode: modeHasAutoApproveTools(mode) ? "workspace-write" : "read-only",
223 goal: "",
224 };
225 }
226
227 export function updateUserPlanModeIntent(
228 current: UserPlanModeIntents,
229 tabId: string | null | undefined,
230 enabled: boolean,
231 ): UserPlanModeIntents {
232 if (!tabId) return current;
233 if (enabled) {
234 return current[tabId] ? current : { ...current, [tabId]: true };
235 }
236 if (!current[tabId]) return current;
237 const next = { ...current };
238 delete next[tabId];
239 return next;
240 }
241
242 export function pruneUserPlanModeIntents(current: UserPlanModeIntents, tabIds: Iterable<string>): UserPlanModeIntents {
243 const live = new Set(tabIds);
244 let changed = false;
245 const next: UserPlanModeIntents = {};
246 for (const tabId of Object.keys(current)) {
247 if (live.has(tabId)) {
248 next[tabId] = true;
249 } else {
250 changed = true;
251 }
252 }
253 return changed ? next : current;
254 }
255
256 export function shouldRestoreUserPlanMode(current: UserPlanModeIntents, tabId: string | null | undefined): boolean {
257 return Boolean(tabId && current[tabId]);
258 }
259
260 export function resolvePlanRestoreTabId(eventTabId: string | null | undefined, activeTabId: string | null | undefined): string | null {
261 return eventTabId || activeTabId || null;
262 }
263
264 export function shouldRestoreUserPlanModeForProfile(
265 current: UserPlanModeIntents,
266 tabId: string | null | undefined,
267 profile?: Pick<ComposerProfile, "goal"> | null,
268 ): boolean {
269 return shouldRestoreUserPlanMode(current, tabId) && !profile?.goal.trim();
270 }
271
271 lines TYPESCRIPT