minecodex 2.0.4 → 2.0.6
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/DESIGN.md +40 -1
- package/README.md +9 -3
- package/apps/dashboard/DESIGN.md +53 -31
- package/apps/dashboard/app.js +55 -39
- package/apps/dashboard/assets/OFL.txt +93 -0
- package/apps/dashboard/assets/info.svg +15 -0
- package/apps/dashboard/assets/moon.svg +13 -0
- package/apps/dashboard/assets/refresh-cw.svg +16 -0
- package/apps/dashboard/assets/silkscreen.ttf +0 -0
- package/apps/dashboard/assets/sun.svg +21 -0
- package/apps/dashboard/assets/terrain.js +89 -0
- package/apps/dashboard/assets/triangle-alert.svg +15 -0
- package/apps/dashboard/favicon.svg +12 -17
- package/apps/dashboard/index.html +24 -18
- package/apps/dashboard/styles.css +147 -181
- package/assets/brand/README.md +19 -0
- package/assets/brand/minecodex-icon-dark.svg +9 -0
- package/assets/brand/minecodex-icon-light.svg +9 -0
- package/assets/brand/minecodex-icon.svg +8 -0
- package/assets/brand/references/lucide-LICENSE.txt +43 -0
- package/assets/readme/hero.svg +93 -0
- package/features/hide-upsell-banner/README.md +7 -5
- package/features/hide-upsell-banner/codex-feature.json +2 -2
- package/features/hide-upsell-banner/src/hide-upsell-banner.mjs +30 -12
- package/features/images/DESIGN.md +117 -41
- package/features/images/README.md +26 -16
- package/features/images/codex-feature.json +6 -3
- package/features/images/src/adapters/codex/image-prompt.mjs +24 -2
- package/features/images/src/adapters/codex/image-sources.mjs +19 -0
- package/features/images/src/adapters/codex/library-api.mjs +2 -0
- package/features/images/src/adapters/codex/library-viewer.mjs +26 -2
- package/features/images/src/adapters/codex/native-image-modules.mjs +211 -0
- package/features/images/src/adapters/codex/native-image-source.mjs +449 -0
- package/features/images/src/backend/local-image-source.mjs +138 -0
- package/features/images/src/frontend/cloud-image-actions.mjs +41 -0
- package/features/images/src/frontend/local-image-source.mjs +195 -0
- package/features/images/src/library-prompt-page.mjs +163 -53
- package/features/images/src/library-prompt-source.mjs +9 -3
- package/features/images/src/prompt-index.mjs +14 -2
- package/features/model-slider/DESIGN.md +2 -0
- package/features/model-slider/codex-feature.json +2 -2
- package/features/model-slider/src/frontend/install.mjs +78 -11
- package/features/notes/DESIGN.md +7 -3
- package/features/notes/codex-feature.json +2 -2
- package/features/notes/src/frontend/entry.mjs +26 -9
- package/features/session-status/DESIGN.md +4 -2
- package/features/session-status/codex-feature.json +2 -2
- package/features/session-status/src/adapters/codex/session-log.mjs +2 -1
- package/features/session-status/src/domain/session-stats.mjs +17 -9
- package/features/session-status/src/session-status.mjs +47 -11
- package/features/sidebar-focus/DESIGN.md +4 -4
- package/features/sidebar-focus/codex-feature.json +3 -2
- package/features/sidebar-focus/src/adapters/codex/renderer.mjs +1 -1
- package/features/sidebar-focus/src/sidebar-focus.mjs +9 -7
- package/features/sites/DESIGN.md +25 -3
- package/features/sites/README.md +6 -2
- package/features/sites/codex-feature.json +3 -2
- package/features/sites/src/adapters/codex/renderer.mjs +25 -4
- package/features/sites/src/backend/project-sites.mjs +18 -6
- package/features/sites/src/backend/sites-service.mjs +22 -10
- package/features/sites/src/domain/discovery-candidates.mjs +53 -0
- package/features/sites/src/frontend/entry.mjs +76 -38
- package/features/sites/src/frontend/styles.css +3 -3
- package/features/unread-inbox/DESIGN.md +24 -25
- package/features/unread-inbox/codex-feature.json +3 -2
- package/features/unread-inbox/src/adapters/codex/modules.mjs +52 -0
- package/features/unread-inbox/src/adapters/codex/renderer.mjs +90 -0
- package/features/unread-inbox/src/domain/notifications.mjs +21 -0
- package/features/unread-inbox/src/injection-source.mjs +5 -2
- package/features/unread-inbox/src/unread-inbox.mjs +212 -297
- package/features/usage-meter/codex-feature.json +2 -2
- package/package.json +11 -3
- package/packages/cli/native/MineCodex.app/Contents/Info.plist +4 -2
- package/packages/cli/native/MineCodex.app/Contents/MacOS/MineCodexServiceManager +0 -0
- package/packages/cli/native/MineCodex.app/Contents/Resources/MineCodex.icns +0 -0
- package/packages/cli/native/MineCodex.app/Contents/_CodeSignature/CodeResources +11 -0
- package/packages/cli/native/README.md +31 -0
- package/packages/cli/src/browser-control-panel.mjs +48 -0
- package/packages/cli/src/commands.mjs +3 -1
- package/packages/cli/src/control-server.mjs +6 -3
- package/packages/cli/src/platform.mjs +5 -0
- package/packages/codex-adapter/README.md +2 -1
- package/packages/codex-adapter/src/renderer/environment.mjs +24 -6
- package/packages/runtime-host/README.md +10 -5
- package/packages/runtime-host/src/codex-injection.mjs +13 -9
- package/packages/runtime-host/src/codex-runtime.mjs +26 -5
- package/packages/runtime-host/src/feature-registry.mjs +1 -0
- package/packages/ui/DESIGN.md +9 -2
- package/packages/ui/assets/arrow-up-right.svg +14 -0
- package/packages/ui/assets/lucide-LICENSE.txt +43 -0
- package/packages/ui/src/components/account-menu.mjs +74 -0
- package/packages/ui/src/components/button.mjs +2 -0
- package/packages/ui/src/components/navigation-rail.mjs +18 -10
- package/packages/ui/src/components/page.mjs +9 -2
- package/packages/ui/src/foundation/layers.mjs +1 -0
- package/packages/ui/src/install.mjs +6 -1
- package/packages/ui/src/native/account-menu.mjs +21 -0
- package/packages/ui/src/native/navigation-rail.mjs +6 -1
- package/packages/ui/src/native/page.mjs +38 -0
- package/packages/ui/src/native/tokens.mjs +4 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import {createNativeImageModules} from "./native-image-modules.mjs";
|
|
2
|
+
|
|
3
|
+
// 原生呈现与上下文只在此适配;构建产物发现由专用模块提供。
|
|
4
|
+
export function createNativeImageSource(codex, { requestHostAction, libraryApi, createCloudImageActions, signal, modules = createNativeImageModules() } = {}) {
|
|
5
|
+
const connect = modules.gallery;
|
|
6
|
+
const opened = new Map();
|
|
7
|
+
|
|
8
|
+
function uploadFile({base64,name}) {
|
|
9
|
+
const decoded=atob(base64),bytes=new Uint8Array(decoded.length);
|
|
10
|
+
// 直接写入字节,避免大图经字符串迭代器创建中间数组。
|
|
11
|
+
for(let index=0;index<decoded.length;index++)bytes[index]=decoded.charCodeAt(index);
|
|
12
|
+
return new File([bytes],name,{type:"image/png"});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function environment(anchor) {
|
|
16
|
+
const providers = [];
|
|
17
|
+
const seen = new Set();
|
|
18
|
+
let scope, libraryScope, intl;
|
|
19
|
+
const inspect = value => {
|
|
20
|
+
const candidate = value?.current ?? value;
|
|
21
|
+
if (!libraryScope && candidate?.value?.basePath && candidate.value.accountId && typeof candidate.get === "function") libraryScope = candidate;
|
|
22
|
+
if (!scope && candidate?.value?.routeKind && typeof candidate.get === "function" && typeof candidate.set === "function" && typeof candidate.query?.getOrFetch === "function") scope = candidate;
|
|
23
|
+
};
|
|
24
|
+
let current, element = anchor;
|
|
25
|
+
while (element && !current) {
|
|
26
|
+
current = codex.currentFiber(element);
|
|
27
|
+
element = element.parentElement;
|
|
28
|
+
}
|
|
29
|
+
for (let fiber = current; fiber; fiber = fiber.return) {
|
|
30
|
+
if (fiber.tag === 10 && !seen.has(fiber.type)) {
|
|
31
|
+
seen.add(fiber.type);
|
|
32
|
+
const value = fiber.memoizedProps?.value;
|
|
33
|
+
providers.push({type:fiber.type, value});
|
|
34
|
+
if (!intl && typeof value?.formatMessage === "function") intl = value;
|
|
35
|
+
}
|
|
36
|
+
inspect(fiber.memoizedProps);
|
|
37
|
+
for (const value of Object.values(fiber.memoizedProps ?? {})) inspect(value);
|
|
38
|
+
for (let hook = fiber.memoizedState, count = 0; hook && count < 140; hook = hook.next, count++) inspect(hook.memoizedState);
|
|
39
|
+
}
|
|
40
|
+
if (!scope || !intl) throw new Error("Native Images context is unavailable");
|
|
41
|
+
return {providers, scope, libraryScope, intl};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function navigationContract(anchor) {
|
|
45
|
+
for (let fiber = codex.currentFiber(anchor); fiber; fiber = fiber.return) {
|
|
46
|
+
const props = fiber.memoizedProps;
|
|
47
|
+
if (typeof fiber.type === "function" && props?.variant === "browse"
|
|
48
|
+
&& props.tabs?.some(item => item.key === "generated")
|
|
49
|
+
&& props.tabs?.some(item => item.key === "uploaded")) return {type:fiber.type, props};
|
|
50
|
+
}
|
|
51
|
+
throw new Error("Native image navigation contract changed");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function mountNavigation(container, anchor, onSelect) {
|
|
55
|
+
const native = await modules.navigation();
|
|
56
|
+
const context = environment(anchor);
|
|
57
|
+
const root = native.ReactDOM.createRoot(container);
|
|
58
|
+
return {
|
|
59
|
+
render(selectedKey) {
|
|
60
|
+
const contract = navigationContract(anchor);
|
|
61
|
+
const props = {
|
|
62
|
+
...contract.props,
|
|
63
|
+
selectedKey,
|
|
64
|
+
tabs:[...contract.props.tabs, {key:"imagegen", name:"Image Gen"}],
|
|
65
|
+
onSelect:key => {
|
|
66
|
+
onSelect(key);
|
|
67
|
+
if (key !== "imagegen") navigationContract(anchor).props.onSelect(key);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
let tree = native.React.createElement(contract.type, props);
|
|
71
|
+
for (const provider of context.providers) {
|
|
72
|
+
tree = native.React.createElement(provider.type, {value:provider.value}, tree);
|
|
73
|
+
}
|
|
74
|
+
root.render(tree);
|
|
75
|
+
},
|
|
76
|
+
dispose() {root.unmount();},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function mount(container, anchor, {onOpen, onPrompt, onRetry, onHidden}) {
|
|
81
|
+
let native = await connect();
|
|
82
|
+
const context = environment(anchor);
|
|
83
|
+
const {React, ReactDOM} = native;
|
|
84
|
+
const h = React.createElement;
|
|
85
|
+
let failure, currentContent, folderDialog, removeDialog, canvasImage, disposed=false, batchBusy=false;
|
|
86
|
+
let currentImages=[],currentLoading=true,selectedIds=new Set(),selectionAnchor=null,openRevision=0;
|
|
87
|
+
const scope = context.libraryScope;
|
|
88
|
+
if (!scope) throw new Error("Native Library context is unavailable");
|
|
89
|
+
const {accountId,userId} = scope.value;
|
|
90
|
+
const message = (id,defaultMessage) => context.intl.formatMessage({id,defaultMessage});
|
|
91
|
+
const toast = () => scope.get(native.toastAtom);
|
|
92
|
+
const sameAccount = () => scope.get(native.accountAtom) === accountId && scope.get(native.userAtom) === userId;
|
|
93
|
+
const isCurrent = () => !disposed && sameAccount();
|
|
94
|
+
const assertAccount = () => {if (!isCurrent()) throw new Error("Library action is no longer current");};
|
|
95
|
+
const reference = (image,operation,extra={}) => requestHostAction("local-image-source",{operation,id:image.id,accountId,userId,...extra});
|
|
96
|
+
async function favoriteStatus(id) {
|
|
97
|
+
const result = await libraryApi.favoriteStatus(id);
|
|
98
|
+
const status = result.items?.find(item => item.target_ref?.library_file_id === id);
|
|
99
|
+
if (status?.availability !== "available") throw new Error("Library favorite status is unavailable");
|
|
100
|
+
return status.favorite?.favorite_id ?? null;
|
|
101
|
+
}
|
|
102
|
+
const cloud = createCloudImageActions({
|
|
103
|
+
reference, assertAccount,
|
|
104
|
+
favoriteStatus,
|
|
105
|
+
setFavorite:(libraryFileId,favoriteId,isFavorited) => native.setFavorite(scope,{libraryFileId,favoriteId,isFavorited,surface:"library_grid",showToast:true}),
|
|
106
|
+
upload:async file => {
|
|
107
|
+
const pendingToast = toast().info(message("minecodex.images.saving","Saving image to Library…"),{duration:0,hasCloseButton:false});
|
|
108
|
+
try {
|
|
109
|
+
const uploaded = await native.upload(scope,uploadFile(file),{
|
|
110
|
+
libraryPersistenceMode:"required",storeInLibrary:true,uploadPurpose:"library",beforeRequest:assertAccount,
|
|
111
|
+
});
|
|
112
|
+
return uploaded.libraryFileId;
|
|
113
|
+
} finally {pendingToast.close();}
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
async function folder(image) {
|
|
117
|
+
const images=Array.isArray(image)?image:[image];
|
|
118
|
+
const registered=[];
|
|
119
|
+
const entries=[];
|
|
120
|
+
for (const item of images) {
|
|
121
|
+
const result=await cloud.registered(item);
|
|
122
|
+
const node=await libraryApi.fileNode(result.libraryFileId);
|
|
123
|
+
assertAccount();
|
|
124
|
+
registered.push(result);
|
|
125
|
+
entries.push({node});
|
|
126
|
+
}
|
|
127
|
+
const created=registered.some(item=>item.created);
|
|
128
|
+
let moved = false;
|
|
129
|
+
folderDialog = h(native.MoveDialog,{
|
|
130
|
+
accountId,userId,entries,initialPath:[],
|
|
131
|
+
onMoved:() => {moved=true;},
|
|
132
|
+
onClose:() => {
|
|
133
|
+
if (!isCurrent()) return;
|
|
134
|
+
folderDialog=null;render(currentContent);
|
|
135
|
+
if (!moved) toast().info(created
|
|
136
|
+
? message("minecodex.images.folderCanceledSaved","Image saved to Library, but not added to a folder.")
|
|
137
|
+
: message("minecodex.images.folderCanceled","No folder selected."));
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
render(currentContent);
|
|
141
|
+
}
|
|
142
|
+
function operationError(error) {
|
|
143
|
+
if (!isCurrent()) return;
|
|
144
|
+
toast().danger(error.code === "UPLOAD_UNCONFIRMED"
|
|
145
|
+
? message("minecodex.images.uploadUnconfirmed","Image upload is pending or could not be confirmed. Check Library before uploading again.")
|
|
146
|
+
: message("minecodex.images.libraryActionFailed","Couldn’t complete the Library action. Please try again."));
|
|
147
|
+
console.error("MineCodex Library action",error);
|
|
148
|
+
}
|
|
149
|
+
const root = ReactDOM.createRoot(container, {onUncaughtError:error => {failure=error;onRetry(error);}});
|
|
150
|
+
function render(content) {
|
|
151
|
+
if (!isCurrent()) return;
|
|
152
|
+
currentContent = content;
|
|
153
|
+
let tree = h(React.Fragment,null,content,folderDialog,removeDialog,selectionToolbar(),canvasImage?h(ImageCanvas,{key:canvasImage.id,image:canvasImage}):null);
|
|
154
|
+
for (const provider of context.providers) tree = h(provider.type, {value:provider.value}, tree);
|
|
155
|
+
root.render(tree);
|
|
156
|
+
}
|
|
157
|
+
function refresh() {
|
|
158
|
+
render(currentLoading ? h(native.Skeleton) : h("div",{className:"pt-4",children:h(native.Masonry,{items:currentImages.map(image=>({id:image.id,content:h(ImageCard,{image})})),animate:true})}));
|
|
159
|
+
}
|
|
160
|
+
function select(image,checked,extendRange=false) {
|
|
161
|
+
if (batchBusy) return;
|
|
162
|
+
const next=native.rangeSelection({orderedIds:currentImages.map(item=>item.id),selectedIds,anchor:selectionAnchor,id:image.id,selected:checked,extendRange});
|
|
163
|
+
selectedIds=next.selectedIds;selectionAnchor=next.anchor;refresh();
|
|
164
|
+
}
|
|
165
|
+
const selectedImages = () => currentImages.filter(image=>selectedIds.has(image.id));
|
|
166
|
+
function clearSelection() {openRevision++;selectedIds=new Set();selectionAnchor=null;removeDialog=null;refresh();}
|
|
167
|
+
async function performBatch(action) {
|
|
168
|
+
if (batchBusy) return;
|
|
169
|
+
batchBusy=true;refresh();
|
|
170
|
+
try {await action();} catch(error) {operationError(error);} finally {batchBusy=false;if(!disposed)refresh();}
|
|
171
|
+
}
|
|
172
|
+
function confirmRemove(images) {
|
|
173
|
+
let pending=false;
|
|
174
|
+
const close=()=>{if(pending)return;removeDialog=null;refresh();};
|
|
175
|
+
const update=()=>{
|
|
176
|
+
removeDialog=h(native.Dialog,{open:true,showDialogClose:false,onOpenChange:open=>{if(!open)close();}},
|
|
177
|
+
h(native.DialogContent,{as:"form",onSubmit:event=>{
|
|
178
|
+
event.preventDefault();if(pending)return;pending=true;update();refresh();
|
|
179
|
+
void requestHostAction("local-image-source",{operation:"hide",ids:images.map(image=>image.id)}).then(result=>{
|
|
180
|
+
if(!isCurrent())return;
|
|
181
|
+
const hidden=new Set(result.hiddenIds);
|
|
182
|
+
currentImages=currentImages.filter(image=>!hidden.has(image.id));
|
|
183
|
+
selectedIds=new Set([...selectedIds].filter(id=>!hidden.has(id)));
|
|
184
|
+
pending=false;removeDialog=null;onHidden?.(result.hiddenIds);refresh();
|
|
185
|
+
toast().info(message("minecodex.images.hidden","Hidden from Image Gen"));
|
|
186
|
+
}).catch(error=>{if(!isCurrent())return;pending=false;update();refresh();operationError(error);});
|
|
187
|
+
}},h(native.ConfirmationContent,{
|
|
188
|
+
title:context.intl.formatMessage({id:"minecodex.images.hideTitle",defaultMessage:"Delete {count, plural, one {image} other {# images}} from Image Gen?"},{count:images.length}),
|
|
189
|
+
description:message("minecodex.images.hideDescription","These images will only be hidden from Image Gen. Local originals and cloud copies will be kept."),
|
|
190
|
+
pending,confirmLabel:message("libraryNext.actions.delete","Delete"),onClose:close,
|
|
191
|
+
})));
|
|
192
|
+
};
|
|
193
|
+
update();refresh();
|
|
194
|
+
}
|
|
195
|
+
function selectionToolbar() {
|
|
196
|
+
if (!selectedIds.size) return null;
|
|
197
|
+
const images=selectedImages();
|
|
198
|
+
const button=(color,label,icon,onClick)=>h(native.Button,{color,radius:"full",size:"toolbar",disabled:batchBusy,onClick,children:[icon,label]});
|
|
199
|
+
const more=h(native.Dropdown,{surface:"opaque",contentWidth:"sm",align:"end",
|
|
200
|
+
triggerButton:h(native.Button,{color:"ghostActive",radius:"full",size:"toolbar",uniform:true,disabled:batchBusy,"aria-label":message("libraryNext.selection.more","More actions"),children:h(native.MoreIcon)}),
|
|
201
|
+
children:h(native.Menu.Item,{leftIcon:native.folderIcon,onSelect:()=>{void performBatch(()=>folder(images));},children:message("libraryNext.selection.move","Move")}),
|
|
202
|
+
});
|
|
203
|
+
return h("div",{className:native.toolbarPositionClass,"data-theme":"dark",children:h("div",{className:native.toolbarClass,role:"toolbar","aria-label":message("libraryNext.selection.actions","Selected item actions"),children:[
|
|
204
|
+
h("span",{className:"min-w-24 shrink-0 whitespace-nowrap tabular-nums select-none",children:context.intl.formatMessage({id:"libraryNext.selection.count",defaultMessage:"{count} selected"},{count:images.length})}),
|
|
205
|
+
button("mediaSolid",message("libraryNext.selection.chat","Start chat"),native.chatIcon,()=>{void performBatch(()=>startChat(images));}),
|
|
206
|
+
button("outline",message("libraryNext.actions.download","Download"),native.downloadIcon,()=>{void performBatch(async()=>{
|
|
207
|
+
for(const image of images){const file=await reference(image,"upload-file");if(await native.downloadImage({downloadSrc:`data:image/png;base64,${file.base64}`,suggestedFilename:file.name})){selectedIds.delete(image.id);}}
|
|
208
|
+
});}),
|
|
209
|
+
button("dangerSolid",message("libraryNext.actions.delete","Delete"),native.deleteIcon,()=>confirmRemove(images)),
|
|
210
|
+
more,h(native.Button,{color:"ghostActive",radius:"full",size:"toolbar",uniform:true,disabled:batchBusy,onClick:clearSelection,"aria-label":message("libraryNext.selection.clear","Clear selection"),children:h(native.ClearIcon)}),
|
|
211
|
+
]})});
|
|
212
|
+
}
|
|
213
|
+
function onSelectionKey(event) {
|
|
214
|
+
if(event.key!=="Escape" || !selectedIds.size || batchBusy || removeDialog || folderDialog || event.defaultPrevented)return;
|
|
215
|
+
if(event.target instanceof Element && event.target.closest("input,textarea,[contenteditable=true],[role=dialog],[role=menu]"))return;
|
|
216
|
+
event.preventDefault();clearSelection();
|
|
217
|
+
}
|
|
218
|
+
document.addEventListener("keydown",onSelectionKey);
|
|
219
|
+
async function startChat(images) {
|
|
220
|
+
assertAccount();
|
|
221
|
+
const conversationId=native.createChatDraft(context.scope,[]);
|
|
222
|
+
native.switchMode(context.scope,"chat");
|
|
223
|
+
const files=[];
|
|
224
|
+
for(const image of images){const file=await reference(image,"upload-file");assertAccount();files.push(native.imageFile(`data:image/png;base64,${file.base64}`,file.name).file);}
|
|
225
|
+
if (!await native.attachChatImages(context.scope,conversationId,files,{isTemporaryChat:false})) throw new Error("Could not attach selected images");
|
|
226
|
+
assertAccount();
|
|
227
|
+
const navigate=context.scope.get(native.routerAtom).prepareNavigation("/",{state:{codexAppMode:"chat",initialChatGptConversationId:conversationId,focusComposerNonce:Date.now()}});
|
|
228
|
+
if(!navigate)throw new Error("Native Chat navigation is unavailable");
|
|
229
|
+
clearSelection();await navigate();
|
|
230
|
+
}
|
|
231
|
+
function localImage(image) {
|
|
232
|
+
return {id:image.imageGenerationItemId, src:image.sourcePath, previewSrc:image.sourcePath, alt:image.name, tabTitle:image.name, width:image.width, height:image.height};
|
|
233
|
+
}
|
|
234
|
+
async function open(image, options) {
|
|
235
|
+
if (!options) {
|
|
236
|
+
assertAccount();
|
|
237
|
+
const revision=++openRevision;
|
|
238
|
+
const canvas=await modules.canvas();
|
|
239
|
+
// 切换来源或重置列表后,不让晚到的画布覆盖当前页面。
|
|
240
|
+
if(revision!==openRevision || !isCurrent())return false;
|
|
241
|
+
native={...native,...canvas};
|
|
242
|
+
opened.set(image.sourcePath,image);
|
|
243
|
+
canvasImage=image;refresh();return true;
|
|
244
|
+
}
|
|
245
|
+
if (signal?.aborted || !sameAccount()) return;
|
|
246
|
+
opened.set(image.sourcePath, image);
|
|
247
|
+
const {readOnly=false,scope=context.scope,chatGptConversationId}=options;
|
|
248
|
+
const result = native.openImage(scope, {
|
|
249
|
+
chatGptConversationId, alt:image.name, title:image.name, attachmentId:`image-playground:${image.imageGenerationItemId}`,
|
|
250
|
+
attachmentSrc:image.sourcePath, src:image.sourcePath, downloadSrc:image.sourcePath,
|
|
251
|
+
imageId:image.imageGenerationItemId, sourceConversationId:image.threadId,
|
|
252
|
+
imageSourceContext:{conversationId:image.threadId, hostId:"local"}, imageSource:"generated",
|
|
253
|
+
readOnly, openInEditor:true,
|
|
254
|
+
renderHeaderMenuActions:({renderMenu}) => renderMenu(h(native.Menu.Item, {onSelect:() => onPrompt(image), children:context.intl.formatMessage({id:"minecodex.images.viewPrompts",defaultMessage:"View prompts"})})),
|
|
255
|
+
}, {addToHistory:false});
|
|
256
|
+
if (!result) throw new Error("Native image preview could not open");
|
|
257
|
+
}
|
|
258
|
+
function ImageCanvas({image}) {
|
|
259
|
+
const resolved=native.useImage({src:image.sourcePath,conversationId:null,shouldLoadFileDataUrl:true});
|
|
260
|
+
const close=()=>{canvasImage=null;refresh();};
|
|
261
|
+
const viewport=container.closest(".contain-layout");
|
|
262
|
+
if(!viewport)throw new Error("Native Library preview viewport changed");
|
|
263
|
+
React.useEffect(()=>{
|
|
264
|
+
const page=container.closest(".overflow-y-auto");
|
|
265
|
+
const previous=page?.inert;
|
|
266
|
+
if(page)page.inert=true;
|
|
267
|
+
return ()=>{if(page)page.inert=previous;};
|
|
268
|
+
},[]);
|
|
269
|
+
const [composerValue]=React.useState(()=>native.createComposerValue({kind:"image",image:{filename:image.name,id:image.id,src:image.sourcePath}},`minecodex-image:${image.id}`));
|
|
270
|
+
const content=h(native.ScopeProvider,{scope:native.composerRouteScope.parent,value:{clientThreadId:`minecodex-image:${image.id}`,routeConversationId:null}},
|
|
271
|
+
h(native.ScopeProvider,{scope:native.composerRouteScope,value:{pathname:"/",routeKind:"home",routeTemplate:"/",search:"",projectContext:null}},
|
|
272
|
+
h(native.ScopeProvider,{scope:native.composerScope,value:composerValue},h(CanvasSurface,{image,resolved,close}))));
|
|
273
|
+
// 原生文件预览占据主内容 viewport,保留导航、二级侧栏和窗口栏。
|
|
274
|
+
return native.createPortal(h("div",{className:"absolute inset-0 z-40 overflow-hidden", "data-minecodex-image-canvas":image.id,children:content}),viewport);
|
|
275
|
+
}
|
|
276
|
+
function CanvasSurface({image,resolved,close}) {
|
|
277
|
+
const controller=native.useComposerController();
|
|
278
|
+
const home=native.useHomeComposer({homeComposerController:controller,homeComposerMode:"chat"});
|
|
279
|
+
const [abort]=React.useState(()=>new AbortController());
|
|
280
|
+
const [markup,setMarkup]=React.useState(false);
|
|
281
|
+
const [manualZoomPercent,setManualZoomPercent]=React.useState(null);
|
|
282
|
+
const [submitting,setSubmitting]=React.useState(false);
|
|
283
|
+
const editorRef=React.useRef(null);
|
|
284
|
+
const attachment=React.useRef(null);
|
|
285
|
+
const submissionAccepted=React.useRef(false);
|
|
286
|
+
React.useEffect(()=>()=>{if(!submissionAccepted.current)abort.abort();},[abort]);
|
|
287
|
+
const assertCurrent=()=>{abort.signal.throwIfAborted();assertAccount();};
|
|
288
|
+
// 查看不上传;首次发送或编辑时才准备原生临时附件,同一画布复用。
|
|
289
|
+
const prepare=async()=>{
|
|
290
|
+
assertCurrent();
|
|
291
|
+
if(!attachment.current)attachment.current=(async()=>{
|
|
292
|
+
const file=await reference(image,"upload-file");assertCurrent();
|
|
293
|
+
const uploaded=await native.upload(context.scope,uploadFile(file),{storeInLibrary:false,beforeRequest:assertCurrent,signal:abort.signal});
|
|
294
|
+
return {...uploaded,width:image.width,height:image.height,imageReference:true};
|
|
295
|
+
})().catch(error=>{attachment.current=null;throw error;});
|
|
296
|
+
return attachment.current;
|
|
297
|
+
};
|
|
298
|
+
if(!home)return null;
|
|
299
|
+
const props=home.composerProps;
|
|
300
|
+
const accepted=(...args)=>{submissionAccepted.current=true;close();props.onSubmitAccepted?.(...args);};
|
|
301
|
+
const requestClose=()=>{if(markup&&editorRef.current)editorRef.current.requestClose();else close();};
|
|
302
|
+
const submitEdit=async request=>{
|
|
303
|
+
if(submitting)return false;
|
|
304
|
+
const editor=markup?editorRef.current:null;
|
|
305
|
+
if(markup&&(!editor||editor.isReadOnly||editor.editor.getSnapshot().hasActiveGesture))return false;
|
|
306
|
+
editor?.commitComment();editor?.editor.commitPendingTextEdit();
|
|
307
|
+
const document=editor?.editor.getSnapshot().effectiveDocument;
|
|
308
|
+
setSubmitting(true);
|
|
309
|
+
try {
|
|
310
|
+
const file=await prepare();assertCurrent();
|
|
311
|
+
const sourceImage={id:image.id,src:`${file.id.startsWith("file_")?"sediment":"file-service"}://${file.id}`,status:"completed",type:"generated-image",width:image.width,height:image.height,sizeBytes:file.size};
|
|
312
|
+
const ok=await native.editImage(context.scope,{
|
|
313
|
+
beforeRequest:assertCurrent,conversationId:props.conversationId,document,image:sourceImage,
|
|
314
|
+
isSubmissionCurrent:()=>!abort.signal.aborted&&!disposed,isTemporaryChat:false,
|
|
315
|
+
request,requireDispatchAcceptance:true,signal:abort.signal,sourceAttachment:file,
|
|
316
|
+
});
|
|
317
|
+
if(ok){assertCurrent();accepted(props.conversationId);}
|
|
318
|
+
return ok;
|
|
319
|
+
}catch(error){if(!abort.signal.aborted)operationError(error);return false;}
|
|
320
|
+
finally {if(!abort.signal.aborted)setSubmitting(false);}
|
|
321
|
+
};
|
|
322
|
+
const composer=h(native.ComposerSurface,{placement:{kind:"home"}},h(native.Composer,{
|
|
323
|
+
...props,composerController:controller,composerLayoutMode:"auto-single-line",lockedLayout:"single-line",
|
|
324
|
+
className:"mx-auto w-full max-w-2xl px-toolbar",errorClassName:"p-3",showFilePicker:false,signal:abort.signal,
|
|
325
|
+
placeholder:message("libraryFilePreview.composerPlaceholder","Ask about this file"),isPrimaryComposer:false,
|
|
326
|
+
disableSubmit:props.disableSubmit||submitting,
|
|
327
|
+
attachmentPreparation:{mimeTypes:["image/png"],prepare:async signal=>{signal?.throwIfAborted();return [await prepare()];}},
|
|
328
|
+
onSubmitAccepted:accepted,
|
|
329
|
+
}));
|
|
330
|
+
const dataUrl=resolved.dataUrl??resolved.previewSrc;
|
|
331
|
+
const canvas=h(native.Canvas,{
|
|
332
|
+
alt:image.name,title:image.name,src:dataUrl??image.sourcePath,downloadSrc:dataUrl??image.sourcePath,downloadFilename:image.name,
|
|
333
|
+
attachmentId:`image-playground:${image.imageGenerationItemId}`,attachmentSrc:image.sourcePath,
|
|
334
|
+
chatGptConversationId:props.conversationId,
|
|
335
|
+
imageSource:"generated",imageSourceContext:{conversationId:image.threadId,hostId:"local"},
|
|
336
|
+
readOnly:false,presentation:"dialog",standalone:true,showViewerHeader:true,onClose:requestClose,
|
|
337
|
+
markupMode:{active:markup,onActiveChange:setMarkup,editorRef,isSubmitting:submitting,standaloneComposer:true},
|
|
338
|
+
onImageEditSubmit:submitEdit,
|
|
339
|
+
zoom:{manualZoomPercent,onZoomPercentChange:setManualZoomPercent,onZoomToFit:()=>setManualZoomPercent(null)},
|
|
340
|
+
renderComposer:visible=>h(React.Activity,{mode:visible?"visible":"hidden",children:composer}),
|
|
341
|
+
renderHeaderMenuActions:({renderMenu})=>renderMenu(h(native.Menu.Item,{onSelect:()=>onPrompt(image),children:message("minecodex.images.viewPrompts","View prompts")})),
|
|
342
|
+
});
|
|
343
|
+
// 原生 overlay 负责底部留白、宽度、避让与 Comment 时的输入框显隐。
|
|
344
|
+
return h(native.CanvasLayout,{assetName:image.name,contentOwnsHeader:true,onClose:requestClose,children:canvas});
|
|
345
|
+
}
|
|
346
|
+
async function chatAbout(image) {
|
|
347
|
+
assertAccount();
|
|
348
|
+
const conversationId = native.createChatDraft(context.scope, []);
|
|
349
|
+
native.switchMode(context.scope, "chat");
|
|
350
|
+
const navigate = context.scope.get(native.routerAtom).prepareNavigation("/", {
|
|
351
|
+
state:{codexAppMode:"chat", initialChatGptConversationId:conversationId, focusComposerNonce:Date.now()},
|
|
352
|
+
});
|
|
353
|
+
if (!navigate) throw new Error("Native Chat navigation is unavailable");
|
|
354
|
+
await navigate();
|
|
355
|
+
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
356
|
+
// 导航会正常卸载图库;只把面板移交给目标 Chat 的可见输入框。
|
|
357
|
+
if (signal?.aborted || !sameAccount()) return;
|
|
358
|
+
const composer = codex.composer(null,{purpose:"insertion"})?.editable;
|
|
359
|
+
if (!composer) throw new Error("Native Chat composer is unavailable");
|
|
360
|
+
const chat = environment(composer);
|
|
361
|
+
await open(image, {readOnly:true, scope:chat.scope, chatGptConversationId:conversationId});
|
|
362
|
+
}
|
|
363
|
+
function ImageCard({image}) {
|
|
364
|
+
const [saved,setSaved] = React.useState(false);
|
|
365
|
+
const [busy,setBusy] = React.useState(false);
|
|
366
|
+
const active=React.useRef(true);
|
|
367
|
+
const favoriteRevision=React.useRef(0);
|
|
368
|
+
React.useEffect(()=>{active.current=true;return()=>{active.current=false;favoriteRevision.current++;};},[]);
|
|
369
|
+
async function perform(action) {
|
|
370
|
+
if (busy) return;
|
|
371
|
+
setBusy(true);
|
|
372
|
+
try {assertAccount();await action();} catch(error) {operationError(error);} finally {if(active.current&&isCurrent())setBusy(false);}
|
|
373
|
+
}
|
|
374
|
+
async function refreshFavorite(open) {
|
|
375
|
+
const revision=++favoriteRevision.current;
|
|
376
|
+
if (!open) return;
|
|
377
|
+
try {
|
|
378
|
+
const cached = await reference(image,"reference");
|
|
379
|
+
const value=cached.libraryFileId ? !!(await favoriteStatus(cached.libraryFileId)) : false;
|
|
380
|
+
if (active.current && isCurrent() && revision===favoriteRevision.current) setSaved(value);
|
|
381
|
+
} catch(error) {console.error("MineCodex favorite status",error);}
|
|
382
|
+
}
|
|
383
|
+
const resolved = native.useImage({src:image.sourcePath, conversationId:image.threadId, hostId:"local", shouldLoadFileDataUrl:true});
|
|
384
|
+
const value = localImage(image);
|
|
385
|
+
const actions = native.imageMenu(context.scope, {hostId:"local",image:value,displaySrc:resolved.previewSrc ?? image.sourcePath,intl:context.intl});
|
|
386
|
+
// Chat about this 组合原生新 Chat 导航、附件与图片面板链路。
|
|
387
|
+
const byId = new Map(actions.map(item => [item.id, item]));
|
|
388
|
+
const action = (id, icon, message) => {
|
|
389
|
+
const item = byId.get(id);
|
|
390
|
+
return item && {...item, icon, text:context.intl.formatMessage(message ?? item.message)};
|
|
391
|
+
};
|
|
392
|
+
const promptGlyph = document.querySelector('[aria-label="Prompts"] svg');
|
|
393
|
+
const menuGlyph = promptGlyph?.cloneNode(true);
|
|
394
|
+
if (menuGlyph) {
|
|
395
|
+
menuGlyph.setAttribute("width","16");menuGlyph.setAttribute("height","16");
|
|
396
|
+
menuGlyph.style.width="16px";menuGlyph.style.height="16px";
|
|
397
|
+
}
|
|
398
|
+
const promptIcon = menuGlyph ? h("span", {className:"inline-flex size-4 items-center justify-center", "aria-hidden":true, dangerouslySetInnerHTML:{__html:menuGlyph.outerHTML}}) : null;
|
|
399
|
+
const menuItems = [
|
|
400
|
+
{id:"chat-about", icon:native.chatIcon, text:context.intl.formatMessage({id:"libraryNext.actions.chatAboutItem",defaultMessage:"Chat about this"}),onSelect:() => {void chatAbout(image).catch(onRetry);}},
|
|
401
|
+
{id:"view-prompts", icon:promptIcon, text:context.intl.formatMessage({id:"minecodex.images.viewPrompts",defaultMessage:"View prompts"}),onSelect:() => {void open(image).then(opened=>{if(opened&&isCurrent())onPrompt(image);}).catch(operationError);}},
|
|
402
|
+
{id:"favorite",icon:native.favoriteIcon(saved),text:saved
|
|
403
|
+
? message("library.favorites.remove","Remove from Favorites")
|
|
404
|
+
: message("library.favorites.add","Add to Favorites"),onSelect:() => {void perform(async () => {
|
|
405
|
+
favoriteRevision.current++;
|
|
406
|
+
const value=await cloud.favorite(image);
|
|
407
|
+
if(active.current&&isCurrent()){favoriteRevision.current++;setSaved(value);}
|
|
408
|
+
});}},
|
|
409
|
+
action("download-a-copy", native.downloadIcon, {id:"libraryNext.actions.download", defaultMessage:"Download"}),
|
|
410
|
+
{id:"folder",icon:native.folderIcon,text:message("libraryNext.actions.addToFolder","Add to folder"),onSelect:() => {void perform(() => folder(image));}},
|
|
411
|
+
{id:"delete",icon:native.deleteIcon,tone:"danger",text:message("libraryNext.actions.delete","Delete"),onSelect:()=>confirmRemove([image])},
|
|
412
|
+
].filter(Boolean);
|
|
413
|
+
const dropdown = h(native.Dropdown, {
|
|
414
|
+
contentWidth:"menu", align:"end", onOpenChange:refreshFavorite,
|
|
415
|
+
triggerButton:h(native.Button,{color:"mediaOverlay",size:"inputIcon",radius:"full","aria-label":context.intl.formatMessage({id:"minecodex.images.actions",defaultMessage:"More actions"}),children:h(native.MoreIcon)}),
|
|
416
|
+
children:menuItems.map(item => h(native.Menu.Item,{key:item.id,disabled:busy || batchBusy || item.disabled,tone:item.tone,leftIcon:item.icon,onSelect:item.onSelect,children:item.text})),
|
|
417
|
+
});
|
|
418
|
+
// 卡片限制比例以容纳控件;超宽或超窄原图完整缩放,不能随卡片比例裁切。
|
|
419
|
+
const imageRatio=image.width>0&&image.height>0?image.width/image.height:1;
|
|
420
|
+
const cardRatio=Math.min(20/13,Math.max(1/2,imageRatio));
|
|
421
|
+
const picture = resolved.previewSrc
|
|
422
|
+
? h("img",{src:resolved.dataUrl ?? resolved.previewSrc,alt:"",loading:"lazy",decoding:"async",draggable:false,className:`absolute inset-0 size-full ${imageRatio===cardRatio?"object-cover":"object-contain"}`})
|
|
423
|
+
: h("span",{className:"absolute inset-0 bg-text/10","aria-busy":!resolved.isError,children:resolved.isError?"Image unavailable":null});
|
|
424
|
+
const checked=selectedIds.has(image.id);
|
|
425
|
+
const selection=h("span",{className:`relative flex items-center ${checked?"":"opacity-0 group-hover:opacity-100 group-has-[:focus-visible]:opacity-100"}`,onClickCapture:event=>{
|
|
426
|
+
if(event.shiftKey){event.preventDefault();select(image,!checked,true);}
|
|
427
|
+
},children:h(native.Checkbox,{className:"cursor-interaction",variant:"overlay",checked,disabled:batchBusy,"aria-label":context.intl.formatMessage({id:"libraryNext.selection.item",defaultMessage:"Select {name}"},{name:image.name}),onCheckedChange:value=>select(image,value===true)})});
|
|
428
|
+
return h(native.Card,{name:image.name,selection,selected:checked,selectionActive:selectedIds.size>0,onSelect:event=>{
|
|
429
|
+
if(selectedIds.size || event.shiftKey){select(image,!checked,event.shiftKey);return true;}return false;
|
|
430
|
+
},onOpen:() => {void Promise.resolve(onOpen(image,open)).catch(operationError);},actions:dropdown,children:h("span",{className:"relative flex min-h-0 w-full flex-1 items-center justify-center",children:h("span",{className:"relative flex w-full items-center justify-center",style:{aspectRatio:cardRatio},children:picture})})});
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
render({images,loading}) {
|
|
434
|
+
if (failure) throw failure;
|
|
435
|
+
currentImages=images;currentLoading=loading;
|
|
436
|
+
if(loading){openRevision++;selectedIds=new Set();selectionAnchor=null;removeDialog=null;scope.set(native.nativeSelectedIds,new Set());}
|
|
437
|
+
refresh();
|
|
438
|
+
},
|
|
439
|
+
clearSelection,
|
|
440
|
+
dispose() {disposed=true;openRevision++;document.removeEventListener("keydown",onSelectionKey);root.unmount();},
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function readViewer(header) {
|
|
445
|
+
const props = codex.props(header, props => typeof props?.attachmentSrc === "string" && opened.has(props.attachmentSrc));
|
|
446
|
+
return props ? opened.get(props.attachmentSrc) : null;
|
|
447
|
+
}
|
|
448
|
+
return {mount, mountNavigation, readViewer};
|
|
449
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import {createHash,randomUUID} from "node:crypto";
|
|
3
|
+
import {readFile,writeFile,mkdir,rename} from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ImageLibrary } from "../image-library.mjs";
|
|
6
|
+
import { ThreadMetadataIndex } from "../thread-metadata.mjs";
|
|
7
|
+
|
|
8
|
+
export class LocalImageSource {
|
|
9
|
+
constructor({
|
|
10
|
+
dataDir,
|
|
11
|
+
sourceRoot = process.env.CODEX_IMAGEGEN_ROOT ?? path.join(os.homedir(), ".codex/generated_images"),
|
|
12
|
+
sessionRoots,
|
|
13
|
+
library,
|
|
14
|
+
} = {}) {
|
|
15
|
+
const root = path.join(os.homedir(), ".codex");
|
|
16
|
+
this.library = library ?? new ImageLibrary({
|
|
17
|
+
sourceRoot,
|
|
18
|
+
dataDir,
|
|
19
|
+
sessionRoots: sessionRoots ?? [
|
|
20
|
+
process.env.CODEX_SESSION_ROOT ?? path.join(root, "sessions"),
|
|
21
|
+
path.join(root, "archived_sessions"),
|
|
22
|
+
],
|
|
23
|
+
threadMetadata: new ThreadMetadataIndex({
|
|
24
|
+
stateDatabasePath: process.env.CODEX_STATE_DATABASE ?? path.join(root, "state_5.sqlite"),
|
|
25
|
+
globalStatePath: process.env.CODEX_GLOBAL_STATE ?? path.join(root, ".codex-global-state.json"),
|
|
26
|
+
sessionIndexPath: process.env.CODEX_SESSION_INDEX ?? path.join(root, "session_index.jsonl"),
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
this.dataDir = dataDir;
|
|
30
|
+
this.hiddenIds = new Set();
|
|
31
|
+
this.hiddenWrites = Promise.resolve();
|
|
32
|
+
this.closed = false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async start() {
|
|
36
|
+
if (this.closed) throw new Error("Local image source is closed");
|
|
37
|
+
if (!this.starting) {
|
|
38
|
+
this.starting = (async () => {
|
|
39
|
+
await this.library.start();
|
|
40
|
+
if (this.dataDir) {
|
|
41
|
+
try {this.hiddenIds = new Set(JSON.parse(await readFile(path.join(this.dataDir,"hidden-image-ids.json"),"utf8")));}
|
|
42
|
+
catch(error) {if(error.code!=="ENOENT") throw error;}
|
|
43
|
+
}
|
|
44
|
+
if (this.closed) throw new Error("Local image source is closed");
|
|
45
|
+
})();
|
|
46
|
+
}
|
|
47
|
+
await this.starting;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
image(image) {
|
|
51
|
+
return {
|
|
52
|
+
id: image.id,
|
|
53
|
+
threadId: image.threadId,
|
|
54
|
+
imageGenerationItemId: image.imageGenerationItemId,
|
|
55
|
+
sourcePath: image.sourcePath,
|
|
56
|
+
name: path.basename(image.sourcePath),
|
|
57
|
+
width: image.width,
|
|
58
|
+
height: image.height,
|
|
59
|
+
threadTitle: image.threadTitle,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async action(payload = {}) {
|
|
64
|
+
const {operation, offset = 0, id, query = ""} = payload;
|
|
65
|
+
if (!["list", "prompt", "reference", "register", "upload-file", "begin-upload", "hide"].includes(operation)) throw new Error("Unknown local image source action");
|
|
66
|
+
if (typeof query !== "string" || query.length > 256) throw new Error("Invalid image query");
|
|
67
|
+
if (operation === "list" && (!Number.isInteger(offset) || offset < 0 || offset > 100000)) {
|
|
68
|
+
throw new Error("Invalid image offset");
|
|
69
|
+
}
|
|
70
|
+
if (operation !== "list" && operation !== "hide" && !/^[a-f0-9]{24}$/.test(id ?? "")) throw new Error("Invalid image ID");
|
|
71
|
+
if (operation === "hide" && (!Array.isArray(payload.ids) || !payload.ids.length || payload.ids.length>1000 || payload.ids.some(value=>typeof value!=="string" || !/^[a-f0-9]{24}$/.test(value)))) throw new Error("Invalid image selection");
|
|
72
|
+
await this.start();
|
|
73
|
+
if (this.closed) throw new Error("Local image source is closed");
|
|
74
|
+
if (operation === "hide") {
|
|
75
|
+
const ids=[...new Set(payload.ids)];
|
|
76
|
+
if (!this.dataDir || ids.some(value=>!this.library.get(value))) throw new Error("Local image selection is unavailable");
|
|
77
|
+
const task=this.hiddenWrites.then(async()=>{
|
|
78
|
+
const hidden=new Set([...this.hiddenIds,...ids]);
|
|
79
|
+
await mkdir(this.dataDir,{recursive:true});
|
|
80
|
+
const file=path.join(this.dataDir,"hidden-image-ids.json"), temporary=`${file}.${randomUUID()}.tmp`;
|
|
81
|
+
await writeFile(temporary,JSON.stringify([...hidden]),{mode:0o600});
|
|
82
|
+
await rename(temporary,file);
|
|
83
|
+
this.hiddenIds=hidden;
|
|
84
|
+
return {hiddenIds:ids};
|
|
85
|
+
});
|
|
86
|
+
this.hiddenWrites=task.catch(()=>{});
|
|
87
|
+
return task;
|
|
88
|
+
}
|
|
89
|
+
if (["reference", "register", "upload-file", "begin-upload"].includes(operation)) {
|
|
90
|
+
const {accountId,userId,libraryFileId,fingerprint} = payload;
|
|
91
|
+
if (![accountId,userId].every(value => typeof value === "string" && /^[\w-]{1,128}$/.test(value))) throw new Error("Invalid Library account");
|
|
92
|
+
if (!this.dataDir) throw new Error("Local image references require a data directory");
|
|
93
|
+
const image = this.library.get(id);
|
|
94
|
+
if (!image) throw new Error("Local image is unavailable");
|
|
95
|
+
// 只读取索引中的图片;内容变化后不沿用旧云端身份。
|
|
96
|
+
const bytes = await readFile(image.sourcePath);
|
|
97
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
98
|
+
const key = createHash("sha256").update(JSON.stringify([accountId,userId,id,digest])).digest("hex");
|
|
99
|
+
const directory = path.join(this.dataDir,"cloud-image-references");
|
|
100
|
+
const file = path.join(directory,`${key}.json`);
|
|
101
|
+
if (operation === "begin-upload") {
|
|
102
|
+
if (fingerprint !== digest) throw new Error("Local image content changed");
|
|
103
|
+
await mkdir(directory,{recursive:true});
|
|
104
|
+
try {await writeFile(file,JSON.stringify({status:"uploading"}),{flag:"wx",mode:0o600});}
|
|
105
|
+
catch(error) {if(error.code==="EEXIST") return {started:false};throw error;}
|
|
106
|
+
return {started:true};
|
|
107
|
+
}
|
|
108
|
+
if (operation === "register") {
|
|
109
|
+
if (fingerprint !== digest || !/^libfile_[\w-]{1,128}$/.test(libraryFileId ?? "")) throw new Error("Invalid Library image reference");
|
|
110
|
+
await mkdir(directory,{recursive:true});
|
|
111
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
112
|
+
await writeFile(temporary,JSON.stringify({libraryFileId}),{mode:0o600});
|
|
113
|
+
await rename(temporary,file);
|
|
114
|
+
return {libraryFileId,fingerprint:digest};
|
|
115
|
+
}
|
|
116
|
+
if (operation === "upload-file") return {fingerprint:digest,name:path.basename(image.sourcePath),base64:bytes.toString("base64")};
|
|
117
|
+
let reference;
|
|
118
|
+
try {reference=JSON.parse(await readFile(file,"utf8"));} catch(error) {if(error.code!=="ENOENT") throw error;}
|
|
119
|
+
return {libraryFileId:reference?.libraryFileId ?? null,status:reference?.status ?? "ready",fingerprint:digest};
|
|
120
|
+
}
|
|
121
|
+
if (operation === "prompt") {
|
|
122
|
+
const image = await this.library.hydratePrompt(id);
|
|
123
|
+
return image?.prompt?.trim() ? {text: image.prompt, kind: "generation"} : null;
|
|
124
|
+
}
|
|
125
|
+
const images=this.hiddenIds.size ? this.library.list({query}).filter(image=>!this.hiddenIds.has(image.id)) : null;
|
|
126
|
+
return {
|
|
127
|
+
images:(images ? images.slice(offset,offset+36) : this.library.list({limit:36,offset,query})).map(image=>this.image(image)),
|
|
128
|
+
total:images ? images.length : this.library.count({query}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async close() {
|
|
133
|
+
this.closed = true;
|
|
134
|
+
await this.starting?.catch(() => {});
|
|
135
|
+
await this.hiddenWrites;
|
|
136
|
+
this.library.close();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// 工作流只管理云端身份与操作顺序;上传协议和原生组件由 Adapter 提供。
|
|
2
|
+
export function createCloudImageActions({reference,upload,favoriteStatus,setFavorite,assertAccount}) {
|
|
3
|
+
const pending = new Map();
|
|
4
|
+
function registered(image) {
|
|
5
|
+
if (!pending.has(image.id)) {
|
|
6
|
+
const task = (async () => {
|
|
7
|
+
assertAccount();
|
|
8
|
+
const cached = await reference(image,"reference");
|
|
9
|
+
assertAccount();
|
|
10
|
+
if (cached.libraryFileId) return {libraryFileId:cached.libraryFileId,created:false};
|
|
11
|
+
const unconfirmed = () => Object.assign(new Error("Library upload is pending or could not be confirmed"),{code:"UPLOAD_UNCONFIRMED"});
|
|
12
|
+
if (cached.status === "uploading") throw unconfirmed();
|
|
13
|
+
const file = await reference(image,"upload-file");
|
|
14
|
+
assertAccount();
|
|
15
|
+
if (!(await reference(image,"begin-upload",{fingerprint:file.fingerprint})).started) throw unconfirmed();
|
|
16
|
+
let libraryFileId;
|
|
17
|
+
try {libraryFileId = await upload(file);} catch(error) {throw Object.assign(unconfirmed(),{cause:error});}
|
|
18
|
+
if (!libraryFileId) throw new Error("Native upload did not return a Library file");
|
|
19
|
+
// 先保存身份;收藏失败或取消文件夹选择后仍可复用上传结果。
|
|
20
|
+
await reference(image,"register",{libraryFileId,fingerprint:file.fingerprint});
|
|
21
|
+
assertAccount();
|
|
22
|
+
return {libraryFileId,created:true};
|
|
23
|
+
})();
|
|
24
|
+
pending.set(image.id,task);
|
|
25
|
+
const release = () => {if (pending.get(image.id) === task) pending.delete(image.id);};
|
|
26
|
+
void task.then(release,release);
|
|
27
|
+
}
|
|
28
|
+
return pending.get(image.id);
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
registered,
|
|
32
|
+
async favorite(image) {
|
|
33
|
+
const {libraryFileId} = await registered(image);
|
|
34
|
+
const favoriteId = await favoriteStatus(libraryFileId);
|
|
35
|
+
assertAccount();
|
|
36
|
+
if (!await setFavorite(libraryFileId,favoriteId,!favoriteId)) throw new Error("Library favorite could not be changed");
|
|
37
|
+
assertAccount();
|
|
38
|
+
return !favoriteId;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|