Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion spx-gui/src/apps/xbuilder/pages/editor/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<header class="flex-none">
<EditorNavbar :project="state?.project ?? null" :state="state" />
</header>
<main class="flex-[1_1_0] flex gap-xl p-4 pt-2">
<main class="flex-[1_1_0] flex" :class="isTutorialCourse ? 'gap-0 p-0' : 'gap-xl p-4 pt-2'">
<UIDetailedLoading v-if="allQueryRet.isLoading.value" :percentage="allQueryRet.progress.value.percentage">
<span>{{ $t(allQueryRet.progress.value.desc ?? { en: 'Loading...', zh: '加载中...' }) }}</span>
</UIDetailedLoading>
Expand Down Expand Up @@ -64,6 +64,7 @@ import { cloudHelpers } from '@/models/common/cloud'
import { localHelpers, type LocalHelpers } from '@/models/common/local'
import type { ProjectSerialized } from '@/models/project'
import { SpxProject } from '@/models/spx/project'
import { useMaybeTutorial } from '@/components/tutorials/tutorial'

const props = defineProps<{
ownerNameInput: string
Expand All @@ -72,6 +73,8 @@ const props = defineProps<{
const localCache = new LocalCache(localHelpers)

const signedInStateQuery = useSignedInStateQuery()
const tutorial = useMaybeTutorial()
const isTutorialCourse = computed(() => tutorial?.currentCourse != null)

const router = useRouter()
const routeProjectIdentifier = computed<ProjectIdentifier>(() => ({
Expand Down
204 changes: 193 additions & 11 deletions spx-gui/src/components/copilot/CopilotUI.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,14 @@ enum TriggerVisibility {

const panelBoundaryBuffer = [20, 20]
const triggerSnapThreshold = 20
const tutorialPanelBottom = 96
const tutorialPanelTopBuffer = 20
const tutorialPanelDefaultHeight = 320
const tutorialPanelMinHeight = 240
</script>

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch, type WatchSource } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch, watchEffect, type WatchSource } from 'vue'
import { useRouter } from 'vue-router'

import { isRectIntersecting, useBottomSticky, useContentSize } from '@/utils/dom'
Expand All @@ -41,13 +45,16 @@ import { type QuickInput, RoundState } from './copilot'
import { useSpotlight } from '@/utils/spotlight'
import type { LocaleMessage } from '@/utils/i18n'
import { homePageName } from '@/apps/xbuilder/router'
import { useMaybeTutorial } from '@/components/tutorials/tutorial'

const copilot = useCopilot()
const spotlight = useSpotlight()
const router = useRouter()
const tutorial = useMaybeTutorial()

const outputRef = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLElement | null>(null)
const tutorialTriggerRef = ref<HTMLElement | null>(null)
const inputRef = ref<InstanceType<typeof CopilotInput>>()
const panelRef = ref<HTMLElement>()

Expand All @@ -66,6 +73,7 @@ const activeRound = computed(() => {
})

const StateIndicator = computed(() => copilot.stateIndicatorComponent)
const isTutorialCourse = computed(() => tutorial?.currentCourse != null)

useBottomSticky(outputRef)

Expand All @@ -76,6 +84,7 @@ const documentElementRef = ref(document.documentElement)
const windowSize = useContentSize(documentElementRef)
const triggerSize = useContentSize(triggerRef)
const panelSize = useContentSize(panelRef as WatchSource<HTMLElement | null>)
const tutorialPanelHeight = localStorageRef('spx-gui-tutorial-copilot-panel-height', tutorialPanelDefaultHeight)

function getCurrentSizes() {
return {
Expand All @@ -88,8 +97,20 @@ function getCurrentSizes() {
}
}

function getMaxTutorialPanelHeight() {
const windowH = windowSize.value?.height ?? window.innerHeight
return Math.max(tutorialPanelMinHeight, windowH - tutorialPanelBottom - tutorialPanelTopBuffer)
}

function getClampedTutorialPanelHeight(height: number) {
return Math.min(getMaxTutorialPanelHeight(), Math.max(tutorialPanelMinHeight, height))
}

// resize the panel to fit the window size

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment (// resize the panel to fit the window size) now under-describes the block: the watch body was extended to also re-clamp tutorialPanelHeight on resize, not just call updatePanelClampedPosition(). Update the comment to reflect both behaviors.

watch(windowSize, updatePanelClampedPosition)
watch(windowSize, () => {
tutorialPanelHeight.value = getClampedTutorialPanelHeight(tutorialPanelHeight.value)
updatePanelClampedPosition()
})
watch(panelSize, () => {
if (panelStatePosition.value.state === State.Move) return // close panel
updatePanelClampedPosition()
Expand Down Expand Up @@ -195,6 +216,36 @@ const triggerState = ref(panelStatePosition.value.state)
const triggerVisibility = ref(TriggerVisibility.None)

const triggerTooltipDisabled = computed(() => !triggerVisibility.value || panelStatePosition.value.state === State.Move)
const panelStyle = computed(() => {
if (isTutorialCourse.value && copilot.active) {
return {
right: '20px',
bottom: `${tutorialPanelBottom}px`,
'--tutorial-copilot-panel-height': `${getClampedTutorialPanelHeight(tutorialPanelHeight.value)}px`
}
}
return { right: `${panelStatePosition.value.right}px`, bottom: `${panelStatePosition.value.bottom}px` }
})

function handleTutorialCopilotTriggerClick() {
if (copilot.active) {
copilot.close()
return
}
openPanel()
}

watchEffect((onCleanup) => {
if (!isTutorialCourse.value || !copilot.active) return
const handlePointerDown = (event: PointerEvent) => {
const target = event.target
if (!(target instanceof Node)) return
if (panelRef.value?.contains(target) || tutorialTriggerRef.value?.contains(target)) return
copilot.close()
}
document.addEventListener('pointerdown', handlePointerDown)
onCleanup(() => document.removeEventListener('pointerdown', handlePointerDown))
})

function updateTriggerVisibility() {
triggerVisibility.value = copilot.active ? TriggerVisibility.None : TriggerVisibility.Visible
Expand Down Expand Up @@ -328,9 +379,24 @@ useDraggable(triggerRef, {
onDragEnd
})
useDraggable(draggerRef, {
onDragStart,
onDragMove,
onDragEnd
onDragStart: () => {
if (isTutorialCourse.value) {
tutorialPanelHeight.value = getClampedTutorialPanelHeight(tutorialPanelHeight.value)
return
}
onDragStart()
},
onDragMove: (offset: Offset) => {
if (isTutorialCourse.value) {
tutorialPanelHeight.value = getClampedTutorialPanelHeight(tutorialPanelHeight.value - offset.y)
return
}
onDragMove(offset)
},
onDragEnd: () => {
if (isTutorialCourse.value) return
onDragEnd()
}
})

const suggestedQuestions: LocaleMessage[] = [
Expand Down Expand Up @@ -448,15 +514,51 @@ onMounted(async () => {
</script>

<template>
<button
v-if="isTutorialCourse"
ref="tutorialTriggerRef"
class="tutorial-copilot-trigger"
:class="{ active: copilot.active }"
:aria-label="$t({ en: 'Open Copilot', zh: '打开 Copilot' })"
@click="handleTutorialCopilotTriggerClick"
>
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect width="40" height="40" rx="12" fill="url(#tutorial_copilot_icon_gradient)" />
<path
d="M27.1326 16.4061C27.6217 17.2175 28.4776 17.7029 29.4224 17.7029C30.6229 17.714 31.7456 16.8507 32.005 15.6613C32.1791 14.9277 32.0383 14.1644 31.6381 13.5346C27.6773 6.67626 17.5584 5.32387 11.9784 10.9817C10.9521 11.9784 10.1369 13.0862 9.51075 14.2867H9.50704L9.43294 14.4386C9.4033 14.4979 9.36995 14.5572 9.34402 14.6165C9.34402 14.6165 9.34772 14.6165 9.35143 14.6128V14.6202C9.35143 14.6202 9.34772 14.6202 9.34402 14.6202C9.33661 14.635 9.3292 14.6535 9.32179 14.6721L9.26991 14.7795C8.6215 16.143 8.21023 17.5436 8.08055 19.1479C7.99162 20.215 8.0472 21.2784 8.23246 22.301C8.43254 23.3607 8.75119 24.3648 9.17358 25.2985L9.20322 25.3615C9.20692 25.3726 9.21433 25.3838 9.21804 25.3949C9.22916 25.4208 9.24398 25.4468 9.25509 25.469L9.32179 25.6098H9.3292C11.8858 30.7526 17.8585 33.6612 23.4867 32.3088C26.6324 31.727 32.3829 27.9589 31.916 24.4019C31.4121 22.038 28.0923 21.6378 26.9511 23.7609C26.173 24.9984 25.1022 25.8914 23.8721 26.5398C22.8939 27.014 21.812 27.2771 20.693 27.2771C20.2743 27.2771 19.8482 27.2364 19.437 27.1623C19.3517 27.1474 19.2628 27.1326 19.1887 27.1326C19.1183 27.1326 19.0664 27.1474 19.0071 27.1808C18.4588 27.492 18.355 27.5476 17.8104 27.8292L17.2138 28.1552C16.5951 28.5221 15.7318 28.9815 15.3538 28.1404V28.1293C15.2983 27.8996 15.3575 27.681 15.3872 27.5735C15.4316 27.4142 15.4761 27.2512 15.5243 27.0696C15.628 26.6806 15.7355 26.273 15.8837 25.8766C15.9541 25.695 15.9689 25.6394 15.7355 25.4208C13.831 23.6275 13.0788 21.397 13.5012 18.8071C13.7569 17.2361 14.4868 15.88 15.6725 14.7721C17.1212 13.4234 18.7885 12.7417 20.6152 12.7417C21.0932 12.7417 21.5934 12.7898 22.0973 12.8862C22.1825 12.901 22.2751 12.9195 22.3603 12.938H22.3826C24.4056 13.3827 26.0025 14.5461 27.1252 16.4024L27.1326 16.4061Z"
fill="white"
/>
<defs>
<linearGradient
id="tutorial_copilot_icon_gradient"
x1="20"
y1="0"
x2="20"
y2="40"
gradientUnits="userSpaceOnUse"
>
<stop class="stop-start" />
<stop offset="1" class="stop-end" />
</linearGradient>
</defs>
</svg>
</button>
<div
v-show="!isTutorialCourse || copilot.active"
ref="panelRef"
class="copilot-panel"
:style="{ right: `${panelStatePosition.right}px`, bottom: `${panelStatePosition.bottom}px` }"
:class="{ 'tutorial-copilot-panel': isTutorialCourse }"
:style="panelStyle"
>
<div class="body" :class="[triggerState]">
<UITooltip placement="right" :disabled="triggerTooltipDisabled">
<template #trigger>
<div ref="triggerRef" :class="['copilot-trigger', triggerState, triggerVisibility]" @click="openPanel()">
<div
v-if="!isTutorialCourse"
ref="triggerRef"
:class="['copilot-trigger', triggerState, triggerVisibility]"
@click="openPanel()"
>
<div class="copilot-trigger-content">
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="12" fill="url(#paint0_linear_931_4390)" />
Expand Down Expand Up @@ -579,19 +681,99 @@ onMounted(async () => {
opacity ease 0.4s;
}

.copilot-trigger .stop-start {
.tutorial-copilot-trigger {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 9999;
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 999px;
background: var(--ui-color-grey-100);
box-shadow: var(--ui-box-shadow-lg);
cursor: pointer;
transition:
transform 0.16s ease,
box-shadow 0.16s ease;
}

.tutorial-copilot-trigger:hover {
transform: translateY(-2px);
box-shadow: var(--ui-box-shadow-lg);
}

.tutorial-copilot-trigger.active {
background: #e7f8f7;
}

.tutorial-copilot-panel {
width: 360px;
}

.tutorial-copilot-panel .body {
box-shadow: var(--ui-box-shadow-lg);
}

.tutorial-copilot-panel .body-wrapper {
height: var(--tutorial-copilot-panel-height, 320px);
display: flex;
flex-direction: column;
}

.tutorial-copilot-panel .body::after {
content: '';
position: absolute;
right: 24px;
bottom: -7px;
width: 14px;
height: 14px;
background: var(--ui-color-grey-100);
border-right: 1px solid rgba(54, 188, 196, 0.28);
border-bottom: 1px solid rgba(54, 188, 196, 0.28);
transform: rotate(45deg);
z-index: 1;
}

.tutorial-copilot-panel .footer {
display: none;
}

.tutorial-copilot-panel .body-wrapper .dragger {
cursor: ns-resize;
}

.tutorial-copilot-panel .body-wrapper .output {
flex: 1 1 auto;
min-height: 0;
max-height: none;
}

.tutorial-copilot-panel .body-wrapper .input {
flex: 0 0 62px;
}

.copilot-trigger .stop-start,
.tutorial-copilot-trigger .stop-start {
stop-color: #9a77ff;
}

.copilot-trigger .stop-end {
.copilot-trigger .stop-end,
.tutorial-copilot-trigger .stop-end {
stop-color: #735ffa;
}

.copilot-trigger:hover .stop-start {
.copilot-trigger:hover .stop-start,
.tutorial-copilot-trigger:hover .stop-start {
stop-color: #ae92ff;
}

.copilot-trigger:hover .stop-end {
.copilot-trigger:hover .stop-end,
.tutorial-copilot-trigger:hover .stop-end {
stop-color: #9181fb;
}

Expand Down
Loading
Loading