<script setup lang="ts">
import { onMounted, onUnmounted, ref, computed, watch } from 'vue';
import { TinyButton, TinyButtonGroup, TinyTooltip } from '@opentiny/vue';
import { GenuiRenderer } from '@opentiny/genui-sdk-vue';
import { IconAi, IconUser } from '@opentiny/tiny-robot-svgs';
import { IconArrowRight, IconPause, IconRefresh, IconStartCircle } from '@opentiny/vue-icon';
import { LinkKey, linkMap } from '@/utils/link';
import { useMobile } from '@/composables/useMobile';
import { splitJsonIntoChunks } from '@/utils/jsonUtil';
import caculatorJson from '@/static/caculator.json';
import todoJson from '@/static/todo.json';
import todoJsonEn from '@/static/todo.en.json';
import { t, locale } from '@/i18n';
const TinyIconArrowRight = IconArrowRight();
const TinyIconPause = IconPause();
const TinyIconRefresh = IconRefresh();
const TinyIconStartCircle = IconStartCircle();
const message = ref<{ role: 'assistant'; content: string } | null>(null);
const extendSelect = ref('element');
const generating = ref(false);
const streamCompleted = ref(false);
const hasPlayedOnce = ref(false);
const preparingPlayback = ref(false);
const userRowVisible = ref(true);
const aiAvatarVisible = ref(true);
const cardVisible = ref(true);
const suppressExitAnimation = ref(false);
const rendererKey = ref(0);
const { isMobile } = useMobile();
let streamGeneration = 0;
let pauseRequested = false;
let resumeChunkIndex = 0;
let revealCardOnFirstChunk = false;
const messageContentMap = {
element: t('extend.prompt.element'),
page: t('extend.prompt.page'),
};
const bubbleContentMap = {
element: t('extend.prompt.elementBubble'),
page: t('extend.prompt.pageBubble'),
};
const inputMessage = computed(
() => `?input-message=${messageContentMap[extendSelect.value as keyof typeof messageContentMap]}`,
);
/** 已开始回放流程(生成中 / 准备中 / 暂停待续)时固定在右下角;仅待播放或重播待命时居中 */
const streamControlsDocked = computed(() => generating.value || preparingPlayback.value || !streamCompleted.value);
const userBubbleContent = computed(
() => bubbleContentMap[extendSelect.value as keyof typeof bubbleContentMap],
);
const getJsonData = (type: string) => {
if (type === 'element') {
return caculatorJson;
}
return locale.value === 'en_US' ? todoJsonEn : todoJson;
};
const bumpStreamGeneration = () => {
streamGeneration += 1;
};
const resetReplayVisualState = () => {
suppressExitAnimation.value = true;
userRowVisible.value = false;
aiAvatarVisible.value = false;
cardVisible.value = false;
message.value = {
role: 'assistant',
content: '',
};
};
const getFullSchemaContent = (type: string) => {
const chunkSize = type === 'element' ? 30 : 20;
return splitJsonIntoChunks(getJsonData(type), chunkSize).join('');
};
const resetToCompletedFrame = (type: string) => {
bumpStreamGeneration();
rendererKey.value += 1;
pauseRequested = false;
resumeChunkIndex = 0;
revealCardOnFirstChunk = false;
generating.value = false;
streamCompleted.value = true;
hasPlayedOnce.value = false;
preparingPlayback.value = false;
suppressExitAnimation.value = false;
userRowVisible.value = true;
aiAvatarVisible.value = true;
cardVisible.value = true;
message.value = {
role: 'assistant',
content: getFullSchemaContent(type),
};
};
const handleExtendClick = (value: string) => {
extendSelect.value = value;
resetToCompletedFrame(value);
};
const customActions = {
saveState: {
execute: () => {
// do nothing
},
},
};
const runChunkStreaming = async (startIndex: number, accumulatedContent: string) => {
const myGen = streamGeneration;
pauseRequested = false;
generating.value = true;
streamCompleted.value = false;
if (!message.value) {
message.value = {
role: 'assistant',
content: '',
};
}
const currentType = extendSelect.value;
const chunkSize = extendSelect.value === 'element' ? 30 : 20;
try {
const jsonData = getJsonData(currentType);
const chunks = splitJsonIntoChunks(jsonData, chunkSize);
let accumulated = accumulatedContent;
for (let i = startIndex; i < chunks.length; i++) {
if (myGen !== streamGeneration) {
generating.value = false;
return;
}
if (pauseRequested) {
resumeChunkIndex = i;
if (message.value) {
message.value.content = accumulated;
}
generating.value = false;
return;
}
accumulated += chunks[i];
if (message.value) {
message.value.content = accumulated;
}
if (revealCardOnFirstChunk && accumulated.length > 0) {
cardVisible.value = true;
revealCardOnFirstChunk = false;
}
if (i === chunks.length - 1) {
generating.value = false;
streamCompleted.value = true;
hasPlayedOnce.value = true;
resumeChunkIndex = 0;
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
if (myGen !== streamGeneration) {
generating.value = false;
return;
}
if (pauseRequested) {
resumeChunkIndex = i + 1;
if (message.value) {
message.value.content = accumulated;
}
generating.value = false;
return;
}
}
} catch (error) {
console.error('Error loading JSON:', error);
generating.value = false;
}
};
const handleCornerPause = () => {
pauseRequested = true;
};
const handleCornerResume = () => {
runChunkStreaming(resumeChunkIndex, message.value?.content ?? '');
};
const handleCornerReplay = () => {
if (preparingPlayback.value) return;
bumpStreamGeneration();
rendererKey.value += 1;
const myGen = streamGeneration;
pauseRequested = false;
resumeChunkIndex = 0;
revealCardOnFirstChunk = false;
preparingPlayback.value = true;
generating.value = false;
resetReplayVisualState();
setTimeout(() => {
if (myGen !== streamGeneration) return;
suppressExitAnimation.value = false;
userRowVisible.value = true;
}, 100);
setTimeout(() => {
if (myGen !== streamGeneration) return;
aiAvatarVisible.value = true;
}, 500);
setTimeout(() => {
if (myGen !== streamGeneration) return;
revealCardOnFirstChunk = true;
streamCompleted.value = false;
preparingPlayback.value = false;
runChunkStreaming(0, '');
}, 700);
};
watch(isMobile, (mobile) => {
if (mobile && extendSelect.value === 'page') {
handleExtendClick('element');
}
});
onMounted(() => {
resetToCompletedFrame(extendSelect.value);
});
onUnmounted(() => {
bumpStreamGeneration();
});
</script>
<template>
<section class="home-extend">
<div class="home-extend-title genui-title">{{ t('extend.title') }}</div>
<tiny-button-group
class="extend-button-group"
:class="{ 'extend-button-group--mobile': isMobile }"
v-model="extendSelect"
>
<tiny-button
class="extend-button extend-button-element-1"
:class="{ 'extend-button-element-active': extendSelect === 'element' }"
:reset-time="0"
value="element"
@click="handleExtendClick('element')"
>
{{ t('extend.calculator') }}
</tiny-button>
<tiny-button
v-if="!isMobile"
class="extend-button extend-button-element-2"
:reset-time="0"
:class="{ 'extend-button-element-active': extendSelect === 'page' }"
value="page"
@click="handleExtendClick('page')"
>
{{ t('extend.todoApp') }}
</tiny-button>
</tiny-button-group>
<div class="home-extend-schema">
<div class="home-extend-schema-header">
<div class="home-extend-schema-header-action">
<div class="home-extend-schema-header-action-close"></div>
<div class="home-extend-schema-header-action-full"></div>
<div class="home-extend-schema-header-action-exit"></div>
</div>
<a
class="home-extend-schema-header-subtitle is-link"
:href="linkMap[LinkKey.Playground] + inputMessage"
target="_blank"
>
<span>{{ t('extend.tryPlayground') }}</span>
<tiny-icon-arrow-right />
</a>
</div>
<div class="home-extend-schema-content">
<div class="home-extend-schema-content-scroll">
<div class="home-extend-schema-content-inner">
<div class="home-extend-chat-mock">
<div
class="home-extend-user-row"
:class="{ 'is-visible': userRowVisible, 'no-exit': suppressExitAnimation }"
>
<div class="home-extend-user-bubble">{{ userBubbleContent }}</div>
<div class="home-extend-avatar home-extend-avatar-user">
<IconUser class="home-extend-avatar-icon" />
</div>
</div>
<div class="home-extend-ai-row">
<div
class="home-extend-avatar home-extend-avatar-ai"
:class="{ 'is-visible': aiAvatarVisible, 'no-exit': suppressExitAnimation }"
>
<IconAi class="home-extend-avatar-icon" />
</div>
<div
class="home-extend-render-area"
:class="{ 'is-visible': cardVisible, 'no-exit': suppressExitAnimation }"
>
<GenuiRenderer
:key="rendererKey"
class="home-extend-schema-renderer"
:content="message?.content || ''"
:generating="generating"
:customActions="customActions"
/>
</div>
</div>
</div>
</div>
</div>
<div
class="home-extend-stream-controls"
:class="{ 'home-extend-stream-controls--docked': streamControlsDocked }"
:aria-label="t('extend.replayControls')"
>
<div class="home-extend-stream-controls-surface">
<tiny-tooltip v-if="generating" :content="t('extend.pause')" placement="top" effect="light">
<tiny-button
class="home-extend-control-btn"
circle
:reset-time="0"
:size="streamControlsDocked ? 'medium' : 'large'"
:icon="TinyIconPause"
@click="handleCornerPause"
/>
</tiny-tooltip>
<tiny-tooltip v-else-if="!streamCompleted" :content="t('extend.resume')" placement="top" effect="light">
<tiny-button
class="home-extend-control-btn"
circle
:reset-time="0"
:size="streamControlsDocked ? 'medium' : 'large'"
:icon="TinyIconStartCircle"
@click="handleCornerResume"
/>
</tiny-tooltip>
<tiny-tooltip
v-else
:content="
preparingPlayback ? t('extend.preparing') : hasPlayedOnce ? t('extend.replay') : t('extend.play')
"
placement="top"
effect="light"
>
<tiny-button
class="home-extend-control-btn"
circle
:reset-time="0"
:size="streamControlsDocked ? 'medium' : 'large'"
:icon="hasPlayedOnce ? TinyIconRefresh : TinyIconStartCircle"
:disabled="preparingPlayback"
@click="handleCornerReplay"
/>
</tiny-tooltip>
</div>
</div>
</div>
</div>
</section>
</template>
<style lang="less" scoped>
.home-extend {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 0px 8%;
&-title {
margin-bottom: 40px;
}
&-schema {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(232, 238, 254, 1), rgba(232, 238, 254, 0.3) 100%);
border-radius: 24px;
padding: 28px;
&-header {
display: flex;
justify-content: space-between;
margin-bottom: 16px;
&-action {
display: flex;
gap: 12px;
div {
width: 16px;
height: 16px;
border-radius: 50%;
@media (min-width: 1920px) {
width: 16px;
height: 16px;
}
}
&-close {
background-color: rgba(254, 3, 4, 1);
}
&-full {
background-color: rgba(254, 199, 3, 1);
}
&-exit {
background-color: rgba(0, 207, 106, 1);
}
}
&-subtitle {
display: flex;
align-items: center;
gap: 8px;
color: rgba(20, 118, 255, 1);
cursor: pointer;
font-size: 16px;
&.is-link {
text-decoration: none;
}
svg {
fill: rgba(20, 118, 255, 1);
}
}
}
&-content {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
min-height: 560px;
height: 590px;
background: #fff;
border-radius: 12px;
overflow: hidden;
box-sizing: border-box;
container-type: size;
container-name: extend-stream;
}
&-content-scroll {
flex: 1 1 0;
min-height: 0;
width: 100%;
overflow-x: hidden;
overflow-y: auto;
box-sizing: border-box;
scrollbar-gutter: stable;
border-bottom-right-radius: 12px;
}
&-content-inner {
padding: 5% 5%;
box-sizing: border-box;
position: relative;
width: 100%;
@media (max-width: 768px) {
padding: 20px;
}
}
@media (min-width: 1280px) {
&-content {
min-height: 640px;
height: 720px;
}
}
}
@media (max-width: 768px) {
&-schema {
padding: 5%;
}
}
@media (min-width: 1920px) {
padding: 0px 240px;
&-title {
margin-bottom: 60px;
}
}
}
.home-extend-chat-mock {
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
}
.home-extend-user-row {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-end;
gap: 16px;
width: 100%;
}
.home-extend-user-row,
.home-extend-avatar-ai,
.home-extend-render-area {
opacity: 0;
transform: translateY(80px);
transition:
opacity 420ms ease,
transform 420ms ease;
&.is-visible {
opacity: 1;
transform: translateY(0);
}
&.no-exit:not(.is-visible) {
transition: none;
}
}
.home-extend-user-bubble {
max-width: min(92%, 720px);
padding: 12px 16px;
border-radius: 14px;
background: rgba(232, 238, 254, 1);
font-size: 14px;
line-height: 1.5;
color: rgba(25, 25, 25, 1);
word-break: break-word;
white-space: pre-wrap;
border-top-right-radius: 0;
margin-top: 8px;
}
.home-extend-ai-row {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
gap: 16px;
width: 100%;
}
.home-extend-avatar {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.home-extend-avatar-icon {
font-size: 40px;
}
.home-extend-render-area {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
justify-content: flex-start;
align-items: flex-start;
border-bottom-right-radius: 12px;
overflow: hidden;
:deep(div.schema-render-container) {
max-width: 100%;
border-bottom-right-radius: 12px;
overflow: hidden;
}
}
.home-extend-stream-controls {
position: absolute;
inset: 0;
z-index: 10;
pointer-events: none;
&-surface {
position: absolute;
left: 50%;
top: 50%;
width: max-content;
pointer-events: auto;
will-change: transform;
transition: transform 0.55s cubic-bezier(0.22, 1, 0.36, 1);
transform: translate(-50%, -50%);
}
&--docked &-surface {
transform: translate(calc(50cqw - 24px - 100%), calc(50cqh - 16px - 100%));
}
@media (prefers-reduced-motion: reduce) {
&-surface {
transition-duration: 0.01ms;
}
}
}
.home-extend-control-btn {
box-shadow: 0 2px 4px #00000029;
}
.extend-button-group {
border-radius: 382px;
width: fit-content;
height: 56px;
background-color: rgba(232, 238, 254, 1);
display: flex;
align-items: center;
padding: 4px;
margin-bottom: 48px;
.extend-button {
height: 100%;
width: 200px;
margin-left: 0;
border-radius: 0;
border: none;
background-color: rgba(232, 238, 254, 1);
font-size: 16px;
&-element-1 {
border-radius: 382px;
}
&-element-2 {
border-radius: 382px;
}
&-element-active {
background-color: #fff;
font-weight: 700;
}
}
@media (max-width: 768px) {
display: none;
}
}
@media (max-width: 768px) {
.home-extend-avatar-user,
.home-extend-avatar-ai {
display: none;
}
.home-extend-schema-content {
border-radius: 6px;
}
}
</style>