feat(web-console): rebuild intelligent cockpit sync interface
This commit is contained in:
@@ -18,6 +18,79 @@
|
||||
const $ = (selector, root = document) => root.querySelector(selector);
|
||||
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
||||
|
||||
function rgbToHsl(red, green, blue) {
|
||||
const r = red / 255;
|
||||
const g = green / 255;
|
||||
const b = blue / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const lightness = (max + min) / 2;
|
||||
const delta = max - min;
|
||||
if (!delta) return [0, 0, lightness];
|
||||
const saturation = delta / (1 - Math.abs(2 * lightness - 1));
|
||||
let hue;
|
||||
if (max === r) hue = ((g - b) / delta) % 6;
|
||||
else if (max === g) hue = (b - r) / delta + 2;
|
||||
else hue = (r - g) / delta + 4;
|
||||
return [((hue * 60) + 360) % 360, saturation, lightness];
|
||||
}
|
||||
|
||||
function hslToRgb(hue, saturation, lightness) {
|
||||
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
|
||||
const section = hue / 60;
|
||||
const intermediate = chroma * (1 - Math.abs((section % 2) - 1));
|
||||
let rgb = [0, 0, 0];
|
||||
if (section < 1) rgb = [chroma, intermediate, 0];
|
||||
else if (section < 2) rgb = [intermediate, chroma, 0];
|
||||
else if (section < 3) rgb = [0, chroma, intermediate];
|
||||
else if (section < 4) rgb = [0, intermediate, chroma];
|
||||
else if (section < 5) rgb = [intermediate, 0, chroma];
|
||||
else rgb = [chroma, 0, intermediate];
|
||||
const match = lightness - chroma / 2;
|
||||
return rgb.map((channel) => Math.round((channel + match) * 255));
|
||||
}
|
||||
|
||||
function parseCssRgb(value) {
|
||||
const color = String(value || "").trim();
|
||||
const shortHex = color.match(/^#([\da-f])([\da-f])([\da-f])$/i);
|
||||
if (shortHex) return shortHex.slice(1).map((channel) => parseInt(channel + channel, 16));
|
||||
const hex = color.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i);
|
||||
if (hex) return hex.slice(1).map((channel) => parseInt(channel, 16));
|
||||
const rgb = color.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
|
||||
return rgb ? rgb.slice(1).map(Number) : null;
|
||||
}
|
||||
|
||||
function createThemedPetDataUrl(sourceImage, accentColor) {
|
||||
const accent = parseCssRgb(accentColor);
|
||||
if (!accent || !sourceImage.naturalWidth || !sourceImage.naturalHeight) return "";
|
||||
const [targetHue] = rgbToHsl(...accent);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = sourceImage.naturalWidth;
|
||||
canvas.height = sourceImage.naturalHeight;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) return "";
|
||||
context.drawImage(sourceImage, 0, 0);
|
||||
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const { data } = imageData;
|
||||
for (let index = 0; index < data.length; index += 4) {
|
||||
if (data[index + 3] === 0) continue;
|
||||
const red = data[index];
|
||||
const green = data[index + 1];
|
||||
const blue = data[index + 2];
|
||||
const maximum = Math.max(red, green, blue);
|
||||
const minimum = Math.min(red, green, blue);
|
||||
const saturation = maximum ? (maximum - minimum) / maximum : 0;
|
||||
const blueDominance = blue - Math.max(red, green);
|
||||
// Only the deliberately saturated blue apparel is themed. The gray-blue fur stays intact.
|
||||
if (blueDominance < 24 || saturation < 0.32) continue;
|
||||
const [, sourceSaturation, lightness] = rgbToHsl(red, green, blue);
|
||||
const themed = hslToRgb(targetHue, sourceSaturation, lightness);
|
||||
[data[index], data[index + 1], data[index + 2]] = themed;
|
||||
}
|
||||
context.putImageData(imageData, 0, 0);
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
function preset(key, name, aliases, category = "custom", risk = "low", visual = null) {
|
||||
return { key, name, aliases, category, risk, visual };
|
||||
}
|
||||
@@ -291,6 +364,10 @@
|
||||
let sharedTwinBand = null;
|
||||
let sharedTwinHome = null;
|
||||
let petTimer = null;
|
||||
let petBlinkTimer = null;
|
||||
let petBlinkResetTimer = null;
|
||||
let petTailTimer = null;
|
||||
let petTailFrame = null;
|
||||
let petPhraseIndex = 0;
|
||||
|
||||
function uid(prefix) {
|
||||
@@ -322,12 +399,11 @@
|
||||
}
|
||||
|
||||
function initialState() {
|
||||
const device = clone(DEFAULT_DEVICE);
|
||||
return {
|
||||
version: 5,
|
||||
devices: [device],
|
||||
capabilities: seedPresets(device),
|
||||
selectedDeviceByType: { light: device.id },
|
||||
devices: [],
|
||||
capabilities: [],
|
||||
selectedDeviceByType: {},
|
||||
sessionStartedAt: null,
|
||||
settings: { actionDelayMs: 520 },
|
||||
history: [],
|
||||
@@ -343,6 +419,12 @@
|
||||
return initialState();
|
||||
}
|
||||
saved.capabilities = Array.isArray(saved.capabilities) ? saved.capabilities : [];
|
||||
const removedDefaultDevice = saved.devices.some((item) => item.id === DEFAULT_DEVICE.id);
|
||||
saved.devices = saved.devices.filter((item) => item.id !== DEFAULT_DEVICE.id);
|
||||
if (removedDefaultDevice) {
|
||||
saved.capabilities = saved.capabilities.filter((item) => item.deviceId !== DEFAULT_DEVICE.id);
|
||||
delete saved.selectedDeviceByType?.light;
|
||||
}
|
||||
saved.history = Array.isArray(saved.history) ? saved.history : [];
|
||||
saved.vehicleExecutions = saved.vehicleExecutions && typeof saved.vehicleExecutions === "object"
|
||||
? saved.vehicleExecutions
|
||||
@@ -520,12 +602,14 @@
|
||||
<aside class="v2-extension-pet" id="extensionDesktopPet" data-mood="idle" aria-live="polite">
|
||||
<span class="v2-extension-pet-bubble" id="extensionPetBubble">嗨,我会陪你学习新能力</span>
|
||||
<button class="v2-extension-pet-body" id="extensionPetBody" type="button" aria-label="网页版桌宠,拖动可移动,点击可互动">
|
||||
<span class="v2-extension-pet-antenna"><i></i></span>
|
||||
<span class="v2-extension-pet-ear left"></span><span class="v2-extension-pet-ear right"></span>
|
||||
<span class="v2-extension-pet-face"><i class="eye left"></i><i class="eye right"></i><b></b><em></em></span>
|
||||
<span class="v2-extension-pet-screen"><i></i><b>EXT</b></span>
|
||||
<span class="v2-extension-pet-foot left"></span><span class="v2-extension-pet-foot right"></span>
|
||||
<span class="v2-extension-pet-tail"></span>
|
||||
<img class="v2-extension-pet-image" src="/assets/pets/self-extension-cat.png?v=3" alt="" draggable="false" />
|
||||
<canvas class="v2-extension-pet-entrance-canvas" width="512" height="512" aria-hidden="true"></canvas>
|
||||
<canvas class="v2-extension-pet-tail-canvas" width="512" height="512" aria-hidden="true"></canvas>
|
||||
<span class="v2-extension-pet-blink" aria-hidden="true">
|
||||
<i class="v2-extension-pet-blink-eye left"></i>
|
||||
<i class="v2-extension-pet-blink-eye right"></i>
|
||||
</span>
|
||||
<span class="v2-extension-pet-mouth-closed" aria-hidden="true"></span>
|
||||
</button>
|
||||
</aside>`;
|
||||
moduleSwitcher.insertAdjacentElement("afterend", root);
|
||||
@@ -1628,8 +1712,340 @@
|
||||
const pet = $("#extensionDesktopPet");
|
||||
const body = $("#extensionPetBody");
|
||||
const bubble = $("#extensionPetBubble");
|
||||
const host = $("#v2SelfExtension");
|
||||
if (!pet || !body || !bubble || !host) return;
|
||||
const image = $(".v2-extension-pet-image", body);
|
||||
const entranceCanvas = $(".v2-extension-pet-entrance-canvas", body);
|
||||
const tailCanvas = $(".v2-extension-pet-tail-canvas", body);
|
||||
const mouth = $(".v2-extension-pet-mouth-closed", body);
|
||||
const entranceContext = entranceCanvas?.getContext("2d");
|
||||
const tailContext = tailCanvas?.getContext("2d");
|
||||
if (!pet || !body || !bubble || !image || !entranceCanvas || !entranceContext
|
||||
|| !tailCanvas || !tailContext || !mouth) return;
|
||||
const sourceImage = new Image();
|
||||
const entranceSourceImage = new Image();
|
||||
const themedEntranceImage = new Image();
|
||||
const ENTRANCE_KEYFRAME_COUNT = 12;
|
||||
const ENTRANCE_CELL_SIZE = 512;
|
||||
const ENTRANCE_DURATION_MS = 2300;
|
||||
const ENTRANCE_WALK_END = 0.6;
|
||||
const ENTRANCE_WALK_STEPS = 5;
|
||||
const ENTRANCE_SETTLE_START_FRAME = 5;
|
||||
const ENTRANCE_WALK_POSES = [
|
||||
0, 1, 2, 3, 4, 3, 2, 1, 0, 1, 2, 3, 4, 3, 2, 1, 0, 1, 2, 3, 4,
|
||||
];
|
||||
let tailWaveQueued = false;
|
||||
let tailWaveSequenceActive = false;
|
||||
let tailWaveReplaying = false;
|
||||
let entranceAnimationFrame = null;
|
||||
let entranceRequested = document.body.dataset.module === "self-extension";
|
||||
let entranceThemeReady = false;
|
||||
let entranceThemeRevision = 0;
|
||||
let extensionWasActive = document.body.dataset.module === "self-extension";
|
||||
const applyPetTheme = () => {
|
||||
if (!sourceImage.complete || !sourceImage.naturalWidth) return;
|
||||
const accent = getComputedStyle(document.body).getPropertyValue("--v2-accent").trim()
|
||||
|| "#0e7773";
|
||||
const themedSource = createThemedPetDataUrl(sourceImage, accent);
|
||||
if (themedSource) image.src = themedSource;
|
||||
image.dataset.themeRole = "user";
|
||||
if (!entranceSourceImage.complete || !entranceSourceImage.naturalWidth) return;
|
||||
const revision = ++entranceThemeRevision;
|
||||
entranceThemeReady = false;
|
||||
const themedEntranceSource = createThemedPetDataUrl(entranceSourceImage, accent);
|
||||
if (!themedEntranceSource) return;
|
||||
themedEntranceImage.addEventListener("load", () => {
|
||||
if (revision !== entranceThemeRevision) return;
|
||||
entranceThemeReady = true;
|
||||
if (entranceRequested) playEntrance();
|
||||
}, { once: true });
|
||||
themedEntranceImage.src = themedEntranceSource;
|
||||
};
|
||||
sourceImage.addEventListener("load", () => applyPetTheme(), { once: true });
|
||||
sourceImage.src = image.getAttribute("src");
|
||||
entranceSourceImage.addEventListener("load", () => applyPetTheme(), { once: true });
|
||||
entranceSourceImage.src = "/assets/pets/self-extension-cat-entrance.png?v=3";
|
||||
const tailSourceCanvas = document.createElement("canvas");
|
||||
const tailSourceContext = tailSourceCanvas.getContext("2d");
|
||||
const tailPoints = [
|
||||
[350, 165], [400, 166], [416, 190], [414, 400], [399, 450], [342, 464],
|
||||
[330, 442], [347, 419], [361, 380], [367, 228], [345, 198],
|
||||
];
|
||||
const tailMotionFrames = [
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, -5, -11, -7, 7, 17],
|
||||
[0, -9, -20, -13, 13, 30],
|
||||
[0, -13, -27, -17, 19, 41],
|
||||
[0, -16, -32, -19, 24, 48],
|
||||
[0, -9, -19, -11, 13, 31],
|
||||
[0, -4, -9, -5, 6, 14],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 2, 5, 3, -2, -6],
|
||||
[0, 3, 7, 4, -3, -8],
|
||||
[0, 1, 3, 2, -1, -3],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
];
|
||||
const interpolateCubic = (before, start, end, after, amount, clampToSegment = false) => {
|
||||
const amountSquared = amount * amount;
|
||||
const amountCubed = amountSquared * amount;
|
||||
const interpolated = 0.5 * (
|
||||
(2 * start)
|
||||
+ (-before + end) * amount
|
||||
+ (2 * before - 5 * start + 4 * end - after) * amountSquared
|
||||
+ (-before + 3 * start - 3 * end + after) * amountCubed
|
||||
);
|
||||
if (!clampToSegment) return interpolated;
|
||||
return Math.max(Math.min(start, end), Math.min(Math.max(start, end), interpolated));
|
||||
};
|
||||
const getTailPose = (progress) => {
|
||||
const framePosition = Math.max(0, Math.min(1, progress)) * (tailMotionFrames.length - 1);
|
||||
const frameIndex = Math.min(tailMotionFrames.length - 2, Math.floor(framePosition));
|
||||
const amount = framePosition - frameIndex;
|
||||
const beforeFrame = tailMotionFrames[Math.max(0, frameIndex - 1)];
|
||||
const startFrame = tailMotionFrames[frameIndex];
|
||||
const endFrame = tailMotionFrames[frameIndex + 1];
|
||||
const afterFrame = tailMotionFrames[Math.min(tailMotionFrames.length - 1, frameIndex + 2)];
|
||||
return startFrame.map((offset, index) => interpolateCubic(
|
||||
beforeFrame[index], offset, endFrame[index], afterFrame[index], amount, true,
|
||||
));
|
||||
};
|
||||
const sampleTailOffset = (pose, distanceFromBase) => {
|
||||
const position = Math.max(0, Math.min(1, distanceFromBase)) * (pose.length - 1);
|
||||
const index = Math.min(pose.length - 2, Math.floor(position));
|
||||
const amount = position - index;
|
||||
const before = pose[Math.max(0, index - 1)];
|
||||
const start = pose[index];
|
||||
const end = pose[index + 1];
|
||||
const after = pose[Math.min(pose.length - 1, index + 2)];
|
||||
return interpolateCubic(before, start, end, after, amount);
|
||||
};
|
||||
const traceTail = (context, scaleX, scaleY) => {
|
||||
context.beginPath();
|
||||
tailPoints.forEach(([x, y], index) => {
|
||||
const method = index ? "lineTo" : "moveTo";
|
||||
context[method](x * scaleX, y * scaleY);
|
||||
});
|
||||
context.closePath();
|
||||
};
|
||||
const stopTailWave = () => {
|
||||
window.clearTimeout(petTailTimer);
|
||||
petTailTimer = null;
|
||||
if (petTailFrame !== null) window.cancelAnimationFrame(petTailFrame);
|
||||
petTailFrame = null;
|
||||
tailWaveQueued = false;
|
||||
tailWaveSequenceActive = false;
|
||||
tailWaveReplaying = false;
|
||||
delete pet.dataset.tailWaving;
|
||||
tailContext.clearRect(0, 0, tailCanvas.width, tailCanvas.height);
|
||||
};
|
||||
const stopEntrance = () => {
|
||||
if (entranceAnimationFrame !== null) {
|
||||
window.cancelAnimationFrame(entranceAnimationFrame);
|
||||
entranceAnimationFrame = null;
|
||||
}
|
||||
delete pet.dataset.entering;
|
||||
entranceCanvas.style.removeProperty("transform");
|
||||
entranceContext.clearRect(0, 0, entranceCanvas.width, entranceCanvas.height);
|
||||
};
|
||||
const drawEntranceKeyframe = (frameIndex) => {
|
||||
entranceContext.drawImage(
|
||||
themedEntranceImage,
|
||||
frameIndex * ENTRANCE_CELL_SIZE,
|
||||
0,
|
||||
ENTRANCE_CELL_SIZE,
|
||||
ENTRANCE_CELL_SIZE,
|
||||
0,
|
||||
0,
|
||||
entranceCanvas.width,
|
||||
entranceCanvas.height,
|
||||
);
|
||||
};
|
||||
const drawEntrancePose = (progress) => {
|
||||
let activeFrame;
|
||||
let travelProgress = 1;
|
||||
let stepBob = 0;
|
||||
if (progress < ENTRANCE_WALK_END) {
|
||||
const walkProgress = Math.max(0, progress / ENTRANCE_WALK_END);
|
||||
const walkStepPosition = walkProgress * ENTRANCE_WALK_STEPS;
|
||||
const completedSteps = Math.min(
|
||||
ENTRANCE_WALK_STEPS - 1,
|
||||
Math.floor(walkStepPosition),
|
||||
);
|
||||
const stepProgress = walkStepPosition - completedSteps;
|
||||
const easedStepProgress = 0.5 - (Math.cos(Math.PI * stepProgress) / 2);
|
||||
travelProgress = (completedSteps + easedStepProgress) / ENTRANCE_WALK_STEPS;
|
||||
stepBob = -Math.sin(Math.PI * stepProgress) * 2.8;
|
||||
const walkPoseIndex = Math.min(
|
||||
ENTRANCE_WALK_POSES.length - 1,
|
||||
Math.round(walkProgress * (ENTRANCE_WALK_POSES.length - 1)),
|
||||
);
|
||||
activeFrame = ENTRANCE_WALK_POSES[walkPoseIndex];
|
||||
} else {
|
||||
const settleProgress = (progress - ENTRANCE_WALK_END) / (1 - ENTRANCE_WALK_END);
|
||||
activeFrame = ENTRANCE_SETTLE_START_FRAME + Math.round(
|
||||
settleProgress * (ENTRANCE_KEYFRAME_COUNT - ENTRANCE_SETTLE_START_FRAME - 1),
|
||||
);
|
||||
}
|
||||
entranceContext.clearRect(0, 0, entranceCanvas.width, entranceCanvas.height);
|
||||
drawEntranceKeyframe(activeFrame);
|
||||
return { travelProgress, stepBob };
|
||||
};
|
||||
function playEntrance() {
|
||||
if (!entranceRequested || !entranceThemeReady
|
||||
|| document.visibilityState !== "visible"
|
||||
|| document.body.dataset.module !== "self-extension") return;
|
||||
entranceRequested = false;
|
||||
stopEntrance();
|
||||
stopTailWave();
|
||||
delete pet.dataset.blinking;
|
||||
pet.dataset.mood = "idle";
|
||||
pet.dataset.entering = "true";
|
||||
const startedAt = performance.now();
|
||||
const renderEntrance = (now) => {
|
||||
if (document.visibilityState !== "visible"
|
||||
|| document.body.dataset.module !== "self-extension") {
|
||||
stopEntrance();
|
||||
return;
|
||||
}
|
||||
const progress = Math.min(1, (now - startedAt) / ENTRANCE_DURATION_MS);
|
||||
const entrancePose = drawEntrancePose(progress);
|
||||
const horizontalOffset = (1 - entrancePose.travelProgress) * (pet.offsetWidth + 34);
|
||||
entranceCanvas.style.transform = `translate3d(${horizontalOffset}px, ${entrancePose.stepBob}px, 0)`;
|
||||
if (progress < 1) {
|
||||
entranceAnimationFrame = window.requestAnimationFrame(renderEntrance);
|
||||
return;
|
||||
}
|
||||
entranceAnimationFrame = null;
|
||||
delete pet.dataset.entering;
|
||||
entranceCanvas.style.removeProperty("transform");
|
||||
entranceContext.clearRect(0, 0, entranceCanvas.width, entranceCanvas.height);
|
||||
if (document.body.dataset.module === "self-extension") {
|
||||
say("自扩展工作台已就绪", "happy");
|
||||
}
|
||||
};
|
||||
entranceAnimationFrame = window.requestAnimationFrame(renderEntrance);
|
||||
}
|
||||
const requestEntrance = () => {
|
||||
entranceRequested = true;
|
||||
if (entranceThemeReady) playEntrance();
|
||||
};
|
||||
const drawTailWaveFrame = (progress) => {
|
||||
if (!image.complete || !image.naturalWidth || !tailSourceContext) return false;
|
||||
const width = image.naturalWidth;
|
||||
const height = image.naturalHeight;
|
||||
if (tailCanvas.width !== width || tailCanvas.height !== height) {
|
||||
tailCanvas.width = width;
|
||||
tailCanvas.height = height;
|
||||
}
|
||||
if (tailSourceCanvas.width !== width || tailSourceCanvas.height !== height) {
|
||||
tailSourceCanvas.width = width;
|
||||
tailSourceCanvas.height = height;
|
||||
}
|
||||
const scaleX = width / 512;
|
||||
const scaleY = height / 512;
|
||||
const tailTop = 171 * scaleY;
|
||||
const tailBottom = 459 * scaleY;
|
||||
const sliceHeight = Math.max(2, Math.round(2 * scaleY));
|
||||
tailContext.clearRect(0, 0, width, height);
|
||||
tailContext.drawImage(image, 0, 0, width, height);
|
||||
tailContext.save();
|
||||
tailContext.globalCompositeOperation = "destination-out";
|
||||
traceTail(tailContext, scaleX, scaleY);
|
||||
tailContext.fill();
|
||||
tailContext.lineWidth = 10 * Math.max(scaleX, scaleY);
|
||||
tailContext.lineJoin = "round";
|
||||
tailContext.stroke();
|
||||
tailContext.restore();
|
||||
tailSourceContext.clearRect(0, 0, width, height);
|
||||
tailSourceContext.save();
|
||||
traceTail(tailSourceContext, scaleX, scaleY);
|
||||
tailSourceContext.clip();
|
||||
tailSourceContext.drawImage(image, 0, 0, width, height);
|
||||
tailSourceContext.restore();
|
||||
const tailPose = getTailPose(progress);
|
||||
for (let y = tailTop; y < tailBottom; y += sliceHeight) {
|
||||
const distanceFromBase = (tailBottom - y) / (tailBottom - tailTop);
|
||||
const offset = sampleTailOffset(tailPose, distanceFromBase) * scaleX;
|
||||
tailContext.drawImage(
|
||||
tailSourceCanvas,
|
||||
0, y, width, sliceHeight + 1,
|
||||
offset, y, width, sliceHeight + 1,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const runTailWave = (isReplay = false) => {
|
||||
petTailTimer = null;
|
||||
if (document.visibilityState !== "visible"
|
||||
|| document.body.dataset.module !== "self-extension"
|
||||
|| !image.complete
|
||||
|| !drawTailWaveFrame(0)) {
|
||||
stopTailWave();
|
||||
return;
|
||||
}
|
||||
const startedAt = performance.now();
|
||||
tailWaveSequenceActive = true;
|
||||
tailWaveReplaying = isReplay;
|
||||
pet.dataset.tailWaving = "true";
|
||||
const renderTailWave = (now) => {
|
||||
const progress = Math.min(1, (now - startedAt) / 1900);
|
||||
if (!drawTailWaveFrame(progress)) {
|
||||
stopTailWave();
|
||||
return;
|
||||
}
|
||||
if (progress < 1) {
|
||||
petTailFrame = window.requestAnimationFrame(renderTailWave);
|
||||
return;
|
||||
}
|
||||
petTailFrame = null;
|
||||
delete pet.dataset.tailWaving;
|
||||
tailContext.clearRect(0, 0, tailCanvas.width, tailCanvas.height);
|
||||
if (!tailWaveReplaying && tailWaveQueued) {
|
||||
tailWaveQueued = false;
|
||||
tailWaveReplaying = true;
|
||||
petTailTimer = window.setTimeout(() => runTailWave(true), 180);
|
||||
return;
|
||||
}
|
||||
tailWaveSequenceActive = false;
|
||||
tailWaveReplaying = false;
|
||||
tailWaveQueued = false;
|
||||
};
|
||||
petTailFrame = window.requestAnimationFrame(renderTailWave);
|
||||
};
|
||||
const requestTailWave = () => {
|
||||
if (pet.dataset.entering === "true") return;
|
||||
if (tailWaveSequenceActive) {
|
||||
if (!tailWaveReplaying) tailWaveQueued = true;
|
||||
return;
|
||||
}
|
||||
runTailWave();
|
||||
};
|
||||
const scheduleBlink = (delay = 2800 + Math.random() * 4200) => {
|
||||
window.clearTimeout(petBlinkTimer);
|
||||
petBlinkTimer = window.setTimeout(() => {
|
||||
if (document.visibilityState !== "visible"
|
||||
|| document.body.dataset.module !== "self-extension"
|
||||
|| pet.dataset.entering === "true") {
|
||||
scheduleBlink();
|
||||
return;
|
||||
}
|
||||
pet.dataset.blinking = "true";
|
||||
window.clearTimeout(petBlinkResetTimer);
|
||||
petBlinkResetTimer = window.setTimeout(() => {
|
||||
delete pet.dataset.blinking;
|
||||
if (Math.random() < 0.22) {
|
||||
petBlinkTimer = window.setTimeout(() => {
|
||||
pet.dataset.blinking = "true";
|
||||
petBlinkResetTimer = window.setTimeout(() => {
|
||||
delete pet.dataset.blinking;
|
||||
scheduleBlink();
|
||||
}, 135);
|
||||
}, 145);
|
||||
} else {
|
||||
scheduleBlink();
|
||||
}
|
||||
}, 135);
|
||||
}, delay);
|
||||
};
|
||||
scheduleBlink(1200 + Math.random() * 1600);
|
||||
const phrases = [
|
||||
"嗨,我会陪你学习新能力",
|
||||
"拖动我可以换个位置",
|
||||
@@ -1639,26 +2055,58 @@
|
||||
];
|
||||
const pointer = { active: false, moved: false, x: 0, y: 0, startX: 0, startY: 0 };
|
||||
|
||||
const animateMouth = () => {
|
||||
if (pet.dataset.entering === "true") return;
|
||||
mouth.getAnimations?.().forEach((animation) => animation.cancel());
|
||||
mouth.animate?.(
|
||||
[
|
||||
{ opacity: 1, offset: 0 },
|
||||
{ opacity: 1, offset: 0.06 },
|
||||
{ opacity: 0, offset: 0.09 },
|
||||
{ opacity: 0, offset: 0.23 },
|
||||
{ opacity: 1, offset: 0.26 },
|
||||
{ opacity: 1, offset: 0.38 },
|
||||
{ opacity: 0, offset: 0.41 },
|
||||
{ opacity: 0, offset: 0.55 },
|
||||
{ opacity: 1, offset: 0.58 },
|
||||
{ opacity: 1, offset: 0.7 },
|
||||
{ opacity: 0, offset: 0.73 },
|
||||
{ opacity: 0, offset: 0.87 },
|
||||
{ opacity: 1, offset: 0.9 },
|
||||
{ opacity: 1, offset: 1 },
|
||||
],
|
||||
{ duration: 1260, easing: "linear" },
|
||||
);
|
||||
};
|
||||
|
||||
const say = (message, mood = "talking") => {
|
||||
bubble.textContent = message;
|
||||
pet.dataset.mood = mood;
|
||||
animateMouth();
|
||||
window.clearTimeout(Number(pet.dataset.moodTimer || 0));
|
||||
pet.dataset.moodTimer = String(window.setTimeout(() => {
|
||||
pet.dataset.mood = "idle";
|
||||
}, 2600));
|
||||
};
|
||||
|
||||
pet.addEventListener("pointerenter", () => {
|
||||
if (pet.dataset.entering === "true") return;
|
||||
requestTailWave();
|
||||
if (pet.dataset.mood === "idle") animateMouth();
|
||||
});
|
||||
|
||||
const clampPosition = (x, y) => {
|
||||
const hostRect = host.getBoundingClientRect();
|
||||
const petRect = pet.getBoundingClientRect();
|
||||
const edgeMargin = 18;
|
||||
const maxLeftShift = Math.max(0, window.innerWidth - pet.offsetWidth - edgeMargin * 2);
|
||||
const maxUpShift = Math.max(0, window.innerHeight - pet.offsetHeight - edgeMargin * 2);
|
||||
return {
|
||||
x: Math.max(-hostRect.width + petRect.width + 10, Math.min(0, x)),
|
||||
y: Math.max(-hostRect.height + petRect.height + 10, Math.min(0, y)),
|
||||
x: Math.max(-maxLeftShift, Math.min(0, x)),
|
||||
y: Math.max(-maxUpShift, Math.min(0, y)),
|
||||
};
|
||||
};
|
||||
|
||||
body.addEventListener("pointerdown", (event) => {
|
||||
if (event.button !== 0) return;
|
||||
if (event.button !== 0 || pet.dataset.entering === "true") return;
|
||||
pointer.active = true;
|
||||
pointer.moved = false;
|
||||
pointer.startX = event.clientX;
|
||||
@@ -1686,7 +2134,8 @@
|
||||
body.addEventListener("pointerup", endDrag);
|
||||
body.addEventListener("pointercancel", endDrag);
|
||||
body.addEventListener("click", () => {
|
||||
if (pointer.moved) return;
|
||||
if (pointer.moved || pet.dataset.entering === "true") return;
|
||||
requestTailWave();
|
||||
petPhraseIndex = (petPhraseIndex + 1) % phrases.length;
|
||||
say(phrases[petPhraseIndex], petPhraseIndex % 2 ? "happy" : "talking");
|
||||
});
|
||||
@@ -1698,15 +2147,30 @@
|
||||
pet.style.setProperty("--pet-y", `${position.y}px`);
|
||||
});
|
||||
window.addEventListener("cognitive-agent:module-change", (event) => {
|
||||
if (event.detail?.module === "self-extension") say("自扩展工作台已就绪", "happy");
|
||||
const extensionIsActive = event.detail?.module === "self-extension";
|
||||
const enteredExtension = extensionIsActive && !extensionWasActive;
|
||||
extensionWasActive = extensionIsActive;
|
||||
if (enteredExtension) {
|
||||
requestEntrance();
|
||||
} else if (!extensionIsActive) {
|
||||
entranceRequested = false;
|
||||
stopEntrance();
|
||||
}
|
||||
});
|
||||
petTimer = window.setInterval(() => {
|
||||
if (document.visibilityState !== "visible"
|
||||
|| document.body.dataset.module !== "self-extension") return;
|
||||
|| document.body.dataset.module !== "self-extension"
|
||||
|| pet.dataset.entering === "true") return;
|
||||
petPhraseIndex = (petPhraseIndex + 1) % phrases.length;
|
||||
say(phrases[petPhraseIndex]);
|
||||
}, 14000);
|
||||
window.addEventListener("pagehide", () => window.clearInterval(petTimer));
|
||||
window.addEventListener("pagehide", () => {
|
||||
window.clearInterval(petTimer);
|
||||
window.clearTimeout(petBlinkTimer);
|
||||
window.clearTimeout(petBlinkResetTimer);
|
||||
stopEntrance();
|
||||
stopTailWave();
|
||||
});
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
@@ -1827,7 +2291,94 @@
|
||||
});
|
||||
}
|
||||
|
||||
function buildUnifiedExtensionSync() {
|
||||
const mount = $("#vehicleExtensionSync");
|
||||
if (!mount) return false;
|
||||
let snapshot = null;
|
||||
|
||||
const render = () => {
|
||||
const health = snapshot?.extension_health || {};
|
||||
const physicalConnected = health.transport !== "simulation"
|
||||
&& Boolean(health.connected || health.peer_connected);
|
||||
const listening = Boolean(health.listening);
|
||||
const sharedCatalog = Array.isArray(snapshot?.extension?.catalog)
|
||||
? snapshot.extension.catalog
|
||||
: [];
|
||||
const mergedCatalog = [...sharedCatalog, ...peripheralCatalog()]
|
||||
.filter((item, index, catalog) => {
|
||||
const identity = item.id || `${item.device_id}:${item.action}`;
|
||||
return catalog.findIndex((candidate) => (
|
||||
candidate.id || `${candidate.device_id}:${candidate.action}`
|
||||
) === identity) === index;
|
||||
});
|
||||
const grouped = new Map();
|
||||
mergedCatalog.forEach((item) => {
|
||||
const deviceId = item.device_id || "external-device";
|
||||
if (!grouped.has(deviceId)) grouped.set(deviceId, {
|
||||
id: deviceId,
|
||||
name: item.device_name || item.type_label || "USB 扩展设备",
|
||||
type: item.type_label || item.device_type || "扩展硬件",
|
||||
actions: [],
|
||||
});
|
||||
grouped.get(deviceId).actions.push(item.action || item.name || "已学习能力");
|
||||
});
|
||||
const devices = [...grouped.values()];
|
||||
const lastAction = snapshot?.extension?.last_action;
|
||||
const lastResult = snapshot?.extension?.result;
|
||||
const panelCollapsed = mount.closest("#v2ExtensionPanel")?.classList.contains("is-collapsed") === true;
|
||||
const statusLabel = physicalConnected
|
||||
? "USB 外设已接入"
|
||||
: devices.length
|
||||
? "外设目录已同步"
|
||||
: health.transport === "simulation"
|
||||
? "扩展仿真链路"
|
||||
: listening
|
||||
? "等待 USB 外设接入"
|
||||
: "扩展链路检查中";
|
||||
const statusNote = physicalConnected
|
||||
? `${health.peer || "GSCU1B"} · 已同步 ${mergedCatalog.length} 项扩展能力`
|
||||
: devices.length
|
||||
? `车机已同步 ${devices.length} 个外设、${mergedCatalog.length} 项扩展能力`
|
||||
: health.message || "插入外设后,车机将自动识别并同步到此处";
|
||||
mount.dataset.hasDevices = String(devices.length > 0);
|
||||
mount.innerHTML = `
|
||||
<header class="v2-extension-sync-heading">
|
||||
<div><small>USB EXTENSION · LIVE</small><h3>外设与扩展能力</h3></div>
|
||||
<span data-state="${physicalConnected || devices.length ? "online" : listening ? "listening" : "offline"}"><i></i>${statusLabel}</span>
|
||||
<button type="button" data-panel-toggle="extensions" aria-label="${panelCollapsed ? "展开" : "收起"}外设与扩展能力"><i data-lucide="panel-right-close"></i><span data-panel-action-label>${panelCollapsed ? "展开" : "收起"}</span></button>
|
||||
</header>
|
||||
<div class="v2-extension-gateway">
|
||||
<span><i data-lucide="usb"></i></span>
|
||||
<div><strong>${statusLabel}</strong><small>${escapeHtml(statusNote)}</small></div>
|
||||
<b>${String(health.transport || "GSCU").toUpperCase()}</b>
|
||||
</div>
|
||||
<div class="v2-extension-device-list">
|
||||
${devices.length ? devices.map((device) => `
|
||||
<article class="v2-extension-synced-device">
|
||||
<header><span><i data-lucide="cpu"></i></span><div><strong>${escapeHtml(device.name)}</strong><small>${escapeHtml(device.type)}</small></div><b>${device.actions.length} 项能力</b></header>
|
||||
<div>${device.actions.slice(0, 8).map((action) => `<span>${escapeHtml(action)}</span>`).join("")}</div>
|
||||
</article>`).join("") : `
|
||||
<div class="v2-extension-empty">
|
||||
<i data-lucide="cable"></i><div><strong>暂无已识别外设</strong><span>外设名称、类型和已学习能力将由车机同步显示</span></div>
|
||||
</div>`}
|
||||
</div>
|
||||
<footer class="v2-extension-linkage" data-active="${String(Boolean(lastAction))}">
|
||||
<i data-lucide="workflow"></i><div><small>最近联动</small><strong>${escapeHtml(lastAction?.command || lastAction?.type || "等待外设与车端设备联动")}</strong></div>
|
||||
<span>${escapeHtml(lastResult?.status || lastResult?.message || "STANDBY")}</span>
|
||||
</footer>`;
|
||||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||
};
|
||||
|
||||
window.addEventListener("cognitive-agent:display-sync", (event) => {
|
||||
snapshot = event.detail || null;
|
||||
render();
|
||||
});
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (buildUnifiedExtensionSync()) return;
|
||||
if (!buildShell()) return;
|
||||
initializeDesktopPet();
|
||||
bindEvents();
|
||||
|
||||
Reference in New Issue
Block a user