fix(workbench-ui): 执行编辑与增强按钮内嵌到提示词输入框
This commit is contained in:
1 parent
ab8a1947d7
commit
a842087590
2 files changed
+994
No files matched your search
@@ -224,10 +224,22 @@ body {
|
||||
width: 100%;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
min-height: 150px;
|
||||
padding-left: 112px;
|
||||
padding-right: 116px;
|
||||
padding-bottom: 44px;
|
||||
}
|
||||
|
||||
.field .prompt-enhance-wrap > .inline-submit-btn {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
bottom: 8px;
|
||||
z-index: 3;
|
||||
height: 30px;
|
||||
min-width: 88px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.field .prompt-enhance-wrap > .prompt-enhance-btn {
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
@@ -245,6 +257,10 @@ body {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-row.action-row-inline {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.edit-progress-wrap {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
|
||||
@@ -0,0 +1,978 @@
|
||||
(() => {
|
||||
const seedImageInput = document.getElementById('seedImageInput');
|
||||
const selectSeedBtn = document.getElementById('selectSeedBtn');
|
||||
const seedFileName = document.getElementById('seedFileName');
|
||||
const parentPostInput = document.getElementById('parentPostInput');
|
||||
const applyParentBtn = document.getElementById('applyParentBtn');
|
||||
const previewShell = document.getElementById('previewShell');
|
||||
const currentImage = document.getElementById('currentImage');
|
||||
const previewEmpty = document.getElementById('previewEmpty');
|
||||
const currentParentId = document.getElementById('currentParentId');
|
||||
const currentMode = document.getElementById('currentMode');
|
||||
const editPromptInput = document.getElementById('editPromptInput');
|
||||
const submitEditBtn = document.getElementById('submitEditBtn');
|
||||
const resetCycleBtn = document.getElementById('resetCycleBtn');
|
||||
const clearHistoryBtn = document.getElementById('clearHistoryBtn');
|
||||
const editStatusText = document.getElementById('editStatusText');
|
||||
const historyCount = document.getElementById('historyCount');
|
||||
const historyEmpty = document.getElementById('historyEmpty');
|
||||
const historyList = document.getElementById('historyList');
|
||||
const editProgressWrap = document.getElementById('editProgressWrap');
|
||||
const editProgressBar = document.getElementById('editProgressBar');
|
||||
const editProgressText = document.getElementById('editProgressText');
|
||||
|
||||
const state = {
|
||||
editing: false,
|
||||
seedImageBase64: '',
|
||||
seedFileName: '',
|
||||
currentImageUrl: '',
|
||||
currentParentPostId: '',
|
||||
currentSourceImageUrl: '',
|
||||
currentModeValue: 'upload',
|
||||
history: [],
|
||||
editRound: 0,
|
||||
};
|
||||
let editProgressTimer = null;
|
||||
let editProgressHideTimer = null;
|
||||
let editProgressValue = 0;
|
||||
let dragCounter = 0;
|
||||
|
||||
function toast(message, type) {
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(message, type);
|
||||
}
|
||||
}
|
||||
|
||||
function getParentMemoryApi() {
|
||||
return window.ParentPostMemory || null;
|
||||
}
|
||||
|
||||
function rememberParentPost(entry) {
|
||||
const api = getParentMemoryApi();
|
||||
if (!api || !entry) return;
|
||||
try {
|
||||
api.remember(entry);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) return false;
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', 'readonly');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.pointerEvents = 'none';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const copied = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
return copied;
|
||||
}
|
||||
|
||||
function shortId(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return '-';
|
||||
if (raw.length <= 14) return raw;
|
||||
return `${raw.slice(0, 7)}...${raw.slice(-7)}`;
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
try {
|
||||
return new Date(ts).toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
});
|
||||
} catch (e) {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
function maskPrompt(prompt) {
|
||||
const raw = String(prompt || '').trim();
|
||||
if (!raw) return '-';
|
||||
return raw;
|
||||
}
|
||||
|
||||
function buildImaginePublicUrl(parentPostId) {
|
||||
return `https://imagine-public.x.ai/imagine-public/images/${parentPostId}.jpg`;
|
||||
}
|
||||
|
||||
function normalizeHttpSourceUrl(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return '';
|
||||
if (raw.startsWith('data:')) return '';
|
||||
if (raw.startsWith('http://') || raw.startsWith('https://')) {
|
||||
return raw;
|
||||
}
|
||||
if (raw.startsWith('/')) {
|
||||
return `${window.location.origin}${raw}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function pickSourceImageUrl(hit, parentPostId, fallbackValue = '') {
|
||||
const candidates = [
|
||||
hit && hit.current_source_image_url,
|
||||
hit && hit.currentSourceImageUrl,
|
||||
hit && hit.source_image_url,
|
||||
hit && hit.sourceImageUrl,
|
||||
hit && hit.image_url,
|
||||
hit && hit.imageUrl,
|
||||
fallbackValue,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const normalized = normalizeHttpSourceUrl(candidate);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
return parentPostId ? buildImaginePublicUrl(parentPostId) : '';
|
||||
}
|
||||
|
||||
function pickPreviewUrl(hit, parentPostId) {
|
||||
const candidates = [
|
||||
hit && hit.imageUrl,
|
||||
hit && hit.image_url,
|
||||
hit && hit.url,
|
||||
hit && hit.sourceImageUrl,
|
||||
hit && hit.source_image_url,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const raw = String(candidate || '').trim();
|
||||
if (raw) return raw;
|
||||
}
|
||||
const source = pickSourceImageUrl(hit, parentPostId);
|
||||
return source || (parentPostId ? buildImaginePublicUrl(parentPostId) : '');
|
||||
}
|
||||
|
||||
function extractParentPostId(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return '';
|
||||
const direct = raw.match(/^[0-9a-fA-F-]{32,36}$/);
|
||||
if (direct) return direct[0];
|
||||
const generated = raw.match(/\/generated\/([0-9a-fA-F-]{32,36})(?:\/|$)/);
|
||||
if (generated) return generated[1];
|
||||
const imaginePublic = raw.match(/\/imagine-public\/images\/([0-9a-fA-F-]{32,36})(?:\.jpg|\/|$)/);
|
||||
if (imaginePublic) return imaginePublic[1];
|
||||
const imagePath = raw.match(/\/images\/([0-9a-fA-F-]{32,36})(?:\.jpg|\/|$)/);
|
||||
if (imagePath) return imagePath[1];
|
||||
const all = raw.match(/([0-9a-fA-F-]{32,36})/g);
|
||||
return all && all.length ? all[all.length - 1] : '';
|
||||
}
|
||||
|
||||
function resolveParentMemoryByText(text) {
|
||||
const input = String(text || '').trim();
|
||||
if (!input) return null;
|
||||
const api = getParentMemoryApi();
|
||||
if (api && typeof api.resolveByText === 'function') {
|
||||
try {
|
||||
const hit = api.resolveByText(input);
|
||||
if (hit && hit.parentPostId) {
|
||||
const parentPostId = String(hit.parentPostId || '').trim();
|
||||
return {
|
||||
...hit,
|
||||
parentPostId,
|
||||
};
|
||||
}
|
||||
return hit;
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
const parentPostId = extractParentPostId(input);
|
||||
if (!parentPostId) return null;
|
||||
return {
|
||||
parentPostId,
|
||||
sourceImageUrl: buildImaginePublicUrl(parentPostId),
|
||||
imageUrl: buildImaginePublicUrl(parentPostId),
|
||||
origin: 'fallback',
|
||||
};
|
||||
}
|
||||
|
||||
function applyParentPostFromText(text, options = {}) {
|
||||
const silent = Boolean(options.silent);
|
||||
const hit = resolveParentMemoryByText(text);
|
||||
if (!hit || !hit.parentPostId) {
|
||||
if (!silent) {
|
||||
toast('未识别到有效 parentPostId', 'warning');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const parentPostId = String(hit.parentPostId || '').trim();
|
||||
const sourceImageUrl = pickSourceImageUrl(hit, parentPostId);
|
||||
const previewUrl = pickPreviewUrl(hit, parentPostId);
|
||||
|
||||
state.currentParentPostId = parentPostId;
|
||||
state.currentSourceImageUrl = sourceImageUrl;
|
||||
state.currentModeValue = 'parent_post';
|
||||
setPreview(previewUrl);
|
||||
updateMeta();
|
||||
setStatus('done', `已加载 parentPostId:${shortId(parentPostId)}`);
|
||||
if (parentPostInput) {
|
||||
parentPostInput.value = parentPostId;
|
||||
}
|
||||
rememberParentPost({
|
||||
parentPostId,
|
||||
sourceImageUrl,
|
||||
imageUrl: previewUrl,
|
||||
origin: 'workbench_paste_apply',
|
||||
});
|
||||
if (!silent) {
|
||||
toast('已载入 parentPostId,可直接继续编辑', 'success');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function setStatus(stateName, text) {
|
||||
if (!editStatusText) return;
|
||||
editStatusText.textContent = text;
|
||||
editStatusText.classList.remove('running', 'done', 'error');
|
||||
if (stateName) {
|
||||
editStatusText.classList.add(stateName);
|
||||
}
|
||||
}
|
||||
|
||||
function clearEditProgressTimer() {
|
||||
if (editProgressTimer) {
|
||||
clearInterval(editProgressTimer);
|
||||
editProgressTimer = null;
|
||||
}
|
||||
if (editProgressHideTimer) {
|
||||
clearTimeout(editProgressHideTimer);
|
||||
editProgressHideTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setEditProgress(value, text) {
|
||||
const safe = Math.max(0, Math.min(100, Math.round(value || 0)));
|
||||
editProgressValue = safe;
|
||||
if (editProgressBar) {
|
||||
editProgressBar.style.width = `${safe}%`;
|
||||
}
|
||||
if (editProgressText) {
|
||||
editProgressText.textContent = text || `编辑中 ${safe}%`;
|
||||
}
|
||||
}
|
||||
|
||||
function showEditProgress() {
|
||||
if (editProgressWrap) {
|
||||
editProgressWrap.classList.add('active');
|
||||
editProgressWrap.classList.remove('is-success', 'is-error');
|
||||
}
|
||||
if (editProgressText) {
|
||||
editProgressText.classList.add('active');
|
||||
}
|
||||
setEditProgress(4, '编辑中 4%');
|
||||
}
|
||||
|
||||
function hideEditProgress() {
|
||||
clearEditProgressTimer();
|
||||
if (editProgressWrap) {
|
||||
editProgressWrap.classList.remove('active', 'is-success', 'is-error');
|
||||
}
|
||||
if (editProgressText) {
|
||||
editProgressText.classList.remove('active');
|
||||
editProgressText.textContent = '编辑中 0%';
|
||||
}
|
||||
if (editProgressBar) {
|
||||
editProgressBar.style.width = '0%';
|
||||
}
|
||||
editProgressValue = 0;
|
||||
}
|
||||
|
||||
function startEditProgress() {
|
||||
clearEditProgressTimer();
|
||||
showEditProgress();
|
||||
}
|
||||
|
||||
function finishEditProgress(success, text) {
|
||||
clearEditProgressTimer();
|
||||
if (!editProgressWrap) return;
|
||||
editProgressWrap.classList.add('active');
|
||||
editProgressWrap.classList.remove('is-success', 'is-error');
|
||||
editProgressWrap.classList.add(success ? 'is-success' : 'is-error');
|
||||
if (editProgressText) {
|
||||
editProgressText.classList.add('active');
|
||||
}
|
||||
setEditProgress(100, text || (success ? '编辑完成 100%' : '编辑失败'));
|
||||
editProgressHideTimer = setTimeout(() => {
|
||||
hideEditProgress();
|
||||
editProgressHideTimer = null;
|
||||
}, 900);
|
||||
}
|
||||
|
||||
async function readFileAsDataUrl(file) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ''));
|
||||
reader.onerror = () => reject(new Error('读取文件失败'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function applySeedImageFile(file, source) {
|
||||
if (!file) return;
|
||||
if (state.editing) {
|
||||
toast('编辑进行中,暂时不能替换首图', 'warning');
|
||||
return;
|
||||
}
|
||||
const mimeType = String(file.type || '');
|
||||
if (mimeType && !mimeType.startsWith('image/')) {
|
||||
toast('仅支持图片文件', 'warning');
|
||||
return;
|
||||
}
|
||||
const dataUrl = await readFileAsDataUrl(file);
|
||||
if (!dataUrl.startsWith('data:image/')) {
|
||||
throw new Error('图片格式不受支持');
|
||||
}
|
||||
|
||||
state.seedImageBase64 = dataUrl;
|
||||
state.seedFileName = file.name || source || '未命名图片';
|
||||
clearHistory();
|
||||
resetCycle(true);
|
||||
setStatus('', '已载入首图,可开始编辑');
|
||||
updateMeta();
|
||||
toast(`${source || '图片'}已载入,链路已重置`, 'success');
|
||||
}
|
||||
|
||||
function setDragActive(active) {
|
||||
if (!previewShell) return;
|
||||
previewShell.classList.toggle('dragover', Boolean(active));
|
||||
}
|
||||
|
||||
function pickImageFileFromDataTransfer(dataTransfer) {
|
||||
if (!dataTransfer) return null;
|
||||
if (dataTransfer.files && dataTransfer.files.length) {
|
||||
for (const file of dataTransfer.files) {
|
||||
if (file && String(file.type || '').startsWith('image/')) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dataTransfer.items && dataTransfer.items.length) {
|
||||
for (const item of dataTransfer.items) {
|
||||
if (!item) continue;
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile ? item.getAsFile() : null;
|
||||
if (file && String(file.type || '').startsWith('image/')) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasFiles(dataTransfer) {
|
||||
if (!dataTransfer) return false;
|
||||
if (dataTransfer.files && dataTransfer.files.length > 0) return true;
|
||||
const types = dataTransfer.types;
|
||||
if (!types) return false;
|
||||
return Array.from(types).includes('Files');
|
||||
}
|
||||
|
||||
function updateMeta() {
|
||||
if (currentParentId) {
|
||||
currentParentId.textContent = state.currentParentPostId || '-';
|
||||
}
|
||||
if (currentMode) {
|
||||
currentMode.textContent = state.currentModeValue || '-';
|
||||
}
|
||||
if (seedFileName) {
|
||||
seedFileName.textContent = state.seedFileName || '未选择文件';
|
||||
}
|
||||
}
|
||||
|
||||
function setPreview(url) {
|
||||
const displayUrl = String(url || '').trim();
|
||||
state.currentImageUrl = displayUrl;
|
||||
if (!currentImage || !previewEmpty) return;
|
||||
if (!displayUrl) {
|
||||
currentImage.src = '';
|
||||
currentImage.classList.add('hidden');
|
||||
previewEmpty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
currentImage.src = displayUrl;
|
||||
currentImage.classList.remove('hidden');
|
||||
previewEmpty.classList.add('hidden');
|
||||
}
|
||||
|
||||
function setEditing(loading) {
|
||||
state.editing = Boolean(loading);
|
||||
if (submitEditBtn) submitEditBtn.disabled = state.editing;
|
||||
if (selectSeedBtn) selectSeedBtn.disabled = state.editing;
|
||||
if (resetCycleBtn) resetCycleBtn.disabled = state.editing;
|
||||
if (clearHistoryBtn) clearHistoryBtn.disabled = state.editing;
|
||||
if (editPromptInput) editPromptInput.disabled = state.editing;
|
||||
|
||||
if (state.editing) {
|
||||
setStatus('running', '编辑中...');
|
||||
if (submitEditBtn) submitEditBtn.textContent = '编辑中...';
|
||||
} else {
|
||||
if (submitEditBtn) submitEditBtn.textContent = '执行编辑';
|
||||
}
|
||||
}
|
||||
|
||||
function setCurrentFromEntry(entry) {
|
||||
if (!entry) return;
|
||||
state.currentParentPostId = String(entry.parentPostId || '').trim();
|
||||
state.currentSourceImageUrl = String(entry.sourceImageUrl || '').trim();
|
||||
state.currentModeValue = String(entry.mode || '').trim() || 'parent_post';
|
||||
if (parentPostInput) {
|
||||
parentPostInput.value = state.currentParentPostId;
|
||||
}
|
||||
setPreview(entry.imageUrl || '');
|
||||
rememberParentPost({
|
||||
parentPostId: state.currentParentPostId,
|
||||
sourceImageUrl: state.currentSourceImageUrl,
|
||||
imageUrl: entry.imageUrl || '',
|
||||
origin: 'workbench_history_apply',
|
||||
});
|
||||
updateMeta();
|
||||
setStatus('done', `已切换到历史版本 #${entry.round}`);
|
||||
}
|
||||
|
||||
function renderHistory() {
|
||||
if (!historyList || !historyEmpty || !historyCount) return;
|
||||
historyList.innerHTML = '';
|
||||
historyCount.textContent = `${state.history.length} 条`;
|
||||
|
||||
if (!state.history.length) {
|
||||
historyEmpty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
historyEmpty.classList.add('hidden');
|
||||
|
||||
state.history.forEach((entry) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'history-item';
|
||||
|
||||
const thumb = document.createElement('img');
|
||||
thumb.className = 'history-thumb';
|
||||
thumb.src = entry.imageUrl || '';
|
||||
thumb.alt = `history-${entry.round}`;
|
||||
thumb.loading = 'lazy';
|
||||
thumb.decoding = 'async';
|
||||
|
||||
const main = document.createElement('div');
|
||||
main.className = 'history-main';
|
||||
|
||||
const line1 = document.createElement('div');
|
||||
line1.className = 'history-line';
|
||||
line1.innerHTML = `<strong>#${entry.round}</strong> · ${formatTime(entry.createdAt)} · ${entry.elapsedMs}ms`;
|
||||
|
||||
const line2 = document.createElement('div');
|
||||
line2.className = 'history-line';
|
||||
line2.innerHTML = `mode=<strong>${entry.mode}</strong> · parentPostId=<strong>${shortId(entry.parentPostId)}</strong>`;
|
||||
|
||||
const prompt = document.createElement('div');
|
||||
prompt.className = 'history-prompt';
|
||||
prompt.textContent = maskPrompt(entry.prompt);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'history-actions';
|
||||
|
||||
const applyBtn = document.createElement('button');
|
||||
applyBtn.type = 'button';
|
||||
applyBtn.className = 'geist-button-outline';
|
||||
applyBtn.textContent = '设为当前';
|
||||
applyBtn.addEventListener('click', () => {
|
||||
setCurrentFromEntry(entry);
|
||||
});
|
||||
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.type = 'button';
|
||||
copyBtn.className = 'geist-button-outline';
|
||||
copyBtn.textContent = '复制ID';
|
||||
copyBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
if (!entry.parentPostId) {
|
||||
toast('当前记录没有 parentPostId', 'warning');
|
||||
return;
|
||||
}
|
||||
const copied = await copyText(entry.parentPostId);
|
||||
if (!copied) {
|
||||
throw new Error('copy_failed');
|
||||
}
|
||||
toast('已复制 parentPostId', 'success');
|
||||
} catch (e) {
|
||||
toast('复制失败', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
actions.appendChild(applyBtn);
|
||||
actions.appendChild(copyBtn);
|
||||
|
||||
main.appendChild(line1);
|
||||
main.appendChild(line2);
|
||||
main.appendChild(prompt);
|
||||
main.appendChild(actions);
|
||||
|
||||
item.appendChild(thumb);
|
||||
item.appendChild(main);
|
||||
historyList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function resetCycle(keepSeedPreview = true) {
|
||||
state.currentParentPostId = '';
|
||||
state.currentSourceImageUrl = '';
|
||||
state.currentModeValue = 'upload';
|
||||
if (parentPostInput) {
|
||||
parentPostInput.value = '';
|
||||
}
|
||||
updateMeta();
|
||||
|
||||
if (keepSeedPreview && state.seedImageBase64) {
|
||||
setPreview(state.seedImageBase64);
|
||||
} else {
|
||||
setPreview('');
|
||||
}
|
||||
setStatus('', '未开始');
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
state.history = [];
|
||||
state.editRound = 0;
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
async function parseErrorText(res) {
|
||||
const text = await res.text();
|
||||
if (!text) return `请求失败:HTTP ${res.status}`;
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
if (data && typeof data === 'object' && data.detail) {
|
||||
return String(data.detail);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
async function requestWorkbenchEditStream(authHeader, body, onProgress) {
|
||||
const payload = {
|
||||
...body,
|
||||
stream: true,
|
||||
};
|
||||
const res = await fetch('/v1/public/imagine/workbench/edit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...buildAuthHeaders(authHeader),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseErrorText(res));
|
||||
}
|
||||
|
||||
const contentType = String(res.headers.get('content-type') || '').toLowerCase();
|
||||
if (!contentType.includes('text/event-stream')) {
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
const reader = res.body && res.body.getReader ? res.body.getReader() : null;
|
||||
if (!reader) {
|
||||
throw new Error('stream_not_supported');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let finalResult = null;
|
||||
let finalError = '';
|
||||
|
||||
function handleChunk(chunkText) {
|
||||
let eventName = 'message';
|
||||
const dataLines = [];
|
||||
const lines = String(chunkText || '').split('\n');
|
||||
for (const lineRaw of lines) {
|
||||
const line = lineRaw.trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
if (!dataLines.length) return;
|
||||
let eventPayload = null;
|
||||
try {
|
||||
eventPayload = JSON.parse(dataLines.join('\n'));
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
if (eventName === 'progress') {
|
||||
if (onProgress && typeof onProgress === 'function') {
|
||||
onProgress(eventPayload || {});
|
||||
}
|
||||
} else if (eventName === 'result') {
|
||||
finalResult = eventPayload || {};
|
||||
} else if (eventName === 'error') {
|
||||
finalError = String((eventPayload && eventPayload.message) || 'workbench_edit_failed');
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
while (true) {
|
||||
const idx = buffer.indexOf('\n\n');
|
||||
if (idx < 0) break;
|
||||
const block = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 2);
|
||||
handleChunk(block);
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
handleChunk(buffer);
|
||||
buffer = '';
|
||||
}
|
||||
|
||||
if (finalError) {
|
||||
throw new Error(finalError);
|
||||
}
|
||||
if (finalResult) {
|
||||
return finalResult;
|
||||
}
|
||||
throw new Error('workbench_edit_stream_empty_result');
|
||||
}
|
||||
|
||||
async function ensurePublicAuth() {
|
||||
if (typeof ensurePublicKey !== 'function') {
|
||||
throw new Error('鉴权脚本未加载');
|
||||
}
|
||||
const authHeader = await ensurePublicKey();
|
||||
if (authHeader === null) {
|
||||
throw new Error('请先登录公开页面');
|
||||
}
|
||||
return authHeader;
|
||||
}
|
||||
|
||||
async function runEdit() {
|
||||
if (state.editing) return;
|
||||
|
||||
const prompt = String(editPromptInput ? editPromptInput.value : '').trim();
|
||||
if (!prompt) {
|
||||
toast('请输入编辑提示词', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.currentParentPostId && !state.seedImageBase64) {
|
||||
toast('请先上传首图', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
let authHeader = '';
|
||||
try {
|
||||
authHeader = await ensurePublicAuth();
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), 'error');
|
||||
if (String(e.message || '').includes('登录')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const body = {
|
||||
prompt,
|
||||
};
|
||||
|
||||
if (state.currentParentPostId) {
|
||||
body.parent_post_id = state.currentParentPostId;
|
||||
if (state.currentSourceImageUrl) {
|
||||
body.source_image_url = state.currentSourceImageUrl;
|
||||
}
|
||||
} else {
|
||||
body.image_base64 = state.seedImageBase64;
|
||||
}
|
||||
|
||||
setEditing(true);
|
||||
startEditProgress();
|
||||
|
||||
try {
|
||||
const payload = await requestWorkbenchEditStream(authHeader, body, (evt) => {
|
||||
const next = Number(evt && evt.progress ? evt.progress : 0);
|
||||
const message = String((evt && evt.message) || '').trim();
|
||||
if (Number.isFinite(next) && next > 0) {
|
||||
const safe = Math.max(editProgressValue, Math.min(99, next));
|
||||
setEditProgress(safe, message ? `${message} · ${safe}%` : `编辑中 ${safe}%`);
|
||||
setStatus('running', message || `编辑中 ${safe}%`);
|
||||
} else if (message) {
|
||||
setEditProgress(editProgressValue, message);
|
||||
setStatus('running', message);
|
||||
}
|
||||
});
|
||||
const imageUrl = String(payload?.data?.[0]?.url || '').trim();
|
||||
if (!imageUrl) {
|
||||
throw new Error('返回结果缺少图片 URL');
|
||||
}
|
||||
|
||||
const previousParentPostId = state.currentParentPostId;
|
||||
const generatedParent = String(
|
||||
payload.current_parent_post_id
|
||||
|| payload.generated_parent_post_id
|
||||
|| payload.input_parent_post_id
|
||||
|| extractParentPostId(imageUrl)
|
||||
).trim();
|
||||
const resolvedParentPostId = generatedParent || previousParentPostId;
|
||||
|
||||
const sourceImageUrl = pickSourceImageUrl(
|
||||
{
|
||||
current_source_image_url: payload.current_source_image_url,
|
||||
source_image_url: payload.source_image_url,
|
||||
imageUrl,
|
||||
},
|
||||
resolvedParentPostId,
|
||||
state.currentSourceImageUrl
|
||||
);
|
||||
|
||||
const mode = String(payload.mode || (state.currentParentPostId ? 'parent_post' : 'upload')).trim();
|
||||
const elapsedMs = Number(payload.elapsed_ms || 0);
|
||||
|
||||
state.currentParentPostId = resolvedParentPostId;
|
||||
state.currentSourceImageUrl = sourceImageUrl;
|
||||
state.currentModeValue = mode || 'parent_post';
|
||||
if (parentPostInput) {
|
||||
parentPostInput.value = state.currentParentPostId || '';
|
||||
}
|
||||
setPreview(imageUrl);
|
||||
updateMeta();
|
||||
|
||||
state.editRound += 1;
|
||||
const entry = {
|
||||
id: `${Date.now()}_${Math.random().toString(16).slice(2, 8)}`,
|
||||
round: state.editRound,
|
||||
prompt,
|
||||
mode: state.currentModeValue,
|
||||
imageUrl,
|
||||
parentPostId: resolvedParentPostId,
|
||||
sourceImageUrl,
|
||||
elapsedMs: Number.isFinite(elapsedMs) ? Math.max(0, Math.round(elapsedMs)) : 0,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
state.history.unshift(entry);
|
||||
renderHistory();
|
||||
rememberParentPost({
|
||||
parentPostId: resolvedParentPostId,
|
||||
sourceImageUrl,
|
||||
imageUrl,
|
||||
origin: 'workbench_edit',
|
||||
});
|
||||
|
||||
finishEditProgress(true, '编辑完成 100%');
|
||||
setStatus('done', `编辑完成 · round #${entry.round}`);
|
||||
toast('编辑成功,已更新当前画面', 'success');
|
||||
} catch (e) {
|
||||
finishEditProgress(false, '编辑失败');
|
||||
setStatus('error', '编辑失败');
|
||||
toast(String(e.message || e), 'error');
|
||||
} finally {
|
||||
setEditing(false);
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
if (selectSeedBtn && seedImageInput) {
|
||||
selectSeedBtn.addEventListener('click', () => {
|
||||
seedImageInput.click();
|
||||
});
|
||||
|
||||
seedImageInput.addEventListener('change', async (event) => {
|
||||
const file = event.target && event.target.files ? event.target.files[0] : null;
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
await applySeedImageFile(file, '本地图片');
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), 'error');
|
||||
} finally {
|
||||
seedImageInput.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (submitEditBtn) {
|
||||
submitEditBtn.addEventListener('click', runEdit);
|
||||
}
|
||||
|
||||
if (applyParentBtn) {
|
||||
applyParentBtn.addEventListener('click', () => {
|
||||
applyParentPostFromText(parentPostInput ? parentPostInput.value : '');
|
||||
});
|
||||
}
|
||||
|
||||
if (parentPostInput) {
|
||||
parentPostInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
applyParentPostFromText(parentPostInput.value);
|
||||
}
|
||||
});
|
||||
parentPostInput.addEventListener('input', () => {
|
||||
const raw = parentPostInput.value.trim();
|
||||
if (!raw) return;
|
||||
applyParentPostFromText(raw, { silent: true });
|
||||
});
|
||||
parentPostInput.addEventListener('paste', (event) => {
|
||||
const text = String(event.clipboardData ? event.clipboardData.getData('text') || '' : '').trim();
|
||||
if (!text) return;
|
||||
event.preventDefault();
|
||||
parentPostInput.value = text;
|
||||
applyParentPostFromText(text, { silent: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (editPromptInput) {
|
||||
editPromptInput.addEventListener('keydown', (event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
runEdit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (resetCycleBtn) {
|
||||
resetCycleBtn.addEventListener('click', () => {
|
||||
resetCycle(true);
|
||||
toast('已重置编辑链路', 'success');
|
||||
});
|
||||
}
|
||||
|
||||
if (clearHistoryBtn) {
|
||||
clearHistoryBtn.addEventListener('click', () => {
|
||||
clearHistory();
|
||||
toast('历史记录已清空', 'success');
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('paste', async (event) => {
|
||||
const dataTransfer = event.clipboardData;
|
||||
const file = pickImageFileFromDataTransfer(dataTransfer);
|
||||
if (file) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await applySeedImageFile(file, '粘贴图片');
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const text = dataTransfer ? String(dataTransfer.getData('text') || '').trim() : '';
|
||||
if (!text) return;
|
||||
const target = event.target;
|
||||
const isTypingInPrompt = target === editPromptInput;
|
||||
const isTypingInParentInput = target === parentPostInput;
|
||||
if (isTypingInPrompt) return;
|
||||
if (!isTypingInParentInput && (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)) {
|
||||
return;
|
||||
}
|
||||
const ok = applyParentPostFromText(text, { silent: true });
|
||||
if (ok) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
if (previewShell) {
|
||||
previewShell.addEventListener('dragenter', (event) => {
|
||||
if (!hasFiles(event.dataTransfer)) return;
|
||||
event.preventDefault();
|
||||
dragCounter += 1;
|
||||
setDragActive(true);
|
||||
});
|
||||
|
||||
previewShell.addEventListener('dragover', (event) => {
|
||||
if (!hasFiles(event.dataTransfer)) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
setDragActive(true);
|
||||
});
|
||||
|
||||
previewShell.addEventListener('dragleave', (event) => {
|
||||
if (!hasFiles(event.dataTransfer)) return;
|
||||
event.preventDefault();
|
||||
dragCounter = Math.max(0, dragCounter - 1);
|
||||
if (dragCounter === 0) {
|
||||
setDragActive(false);
|
||||
}
|
||||
});
|
||||
|
||||
previewShell.addEventListener('drop', async (event) => {
|
||||
event.preventDefault();
|
||||
dragCounter = 0;
|
||||
setDragActive(false);
|
||||
const file = pickImageFileFromDataTransfer(event.dataTransfer);
|
||||
if (!file) {
|
||||
toast('未检测到可用图片文件', 'warning');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await applySeedImageFile(file, '拖拽图片');
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('dragover', (event) => {
|
||||
if (!hasFiles(event.dataTransfer)) return;
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
window.addEventListener('drop', (event) => {
|
||||
if (!hasFiles(event.dataTransfer)) return;
|
||||
if (previewShell && previewShell.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
setDragActive(false);
|
||||
dragCounter = 0;
|
||||
});
|
||||
}
|
||||
|
||||
function mountInlineSubmitButton() {
|
||||
if (!editPromptInput || !submitEditBtn) return false;
|
||||
const promptWrap = editPromptInput.closest('.prompt-enhance-wrap');
|
||||
if (!promptWrap) return false;
|
||||
if (!promptWrap.contains(submitEditBtn)) {
|
||||
const actionRow = submitEditBtn.parentElement;
|
||||
promptWrap.appendChild(submitEditBtn);
|
||||
if (actionRow && actionRow.classList && actionRow.classList.contains('action-row')) {
|
||||
actionRow.classList.add('action-row-inline');
|
||||
}
|
||||
}
|
||||
submitEditBtn.classList.add('inline-submit-btn');
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureInlineSubmitButton(attempt = 0) {
|
||||
if (mountInlineSubmitButton()) return;
|
||||
if (attempt >= 20) return;
|
||||
setTimeout(() => ensureInlineSubmitButton(attempt + 1), 50);
|
||||
}
|
||||
|
||||
function init() {
|
||||
ensureInlineSubmitButton();
|
||||
bindEvents();
|
||||
renderHistory();
|
||||
resetCycle(false);
|
||||
updateMeta();
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
Reference in new issue
Block a user