Skip to content

feat: Add mobile mode to the conversation, support ctrl/shift/cmd/opt… #2615

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 19, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
Binary file added ui/src/assets/window3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 7 additions & 5 deletions ui/src/components/ai-chat/component/chat-input-operate/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -525,23 +525,25 @@ function autoSendMessage() {
}

function sendChatHandle(event?: any) {
if (!event?.ctrlKey) {
// 如果没有按下组合键ctrl,则会阻止默认事件
if (!event?.ctrlKey && !event?.shiftKey && !event?.altKey && !event?.metaKey) {
// 如果没有按下组合键,则会阻止默认事件
event?.preventDefault()
if (!isDisabledChat.value && !props.loading && !event?.isComposing) {
if (inputValue.value.trim()) {
autoSendMessage()
}
}
} else {
// 如果同时按下ctrl+回车键,则会换行
insertNewlineAtCursor()
// 如果同时按下ctrl/shift/cmd/opt +enter,则会换行
insertNewlineAtCursor(event)
}
}
const insertNewlineAtCursor = () => {
const insertNewlineAtCursor = (event?: any) => {
const textarea = document.querySelector('.el-textarea__inner') as HTMLTextAreaElement
const startPos = textarea.selectionStart
const endPos = textarea.selectionEnd
// 阻止默认行为(避免额外的换行符)
event.preventDefault()
// 在光标处插入换行符
inputValue.value = inputValue.value.slice(0, startPos) + '\n' + inputValue.value.slice(endPos)
nextTick(() => {
Expand Down
18 changes: 13 additions & 5 deletions ui/src/components/ai-chat/index.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<div ref="aiChatRef" class="ai-chat" :class="type">
<div
v-show="(isUserInput && firsUserInput) || showUserInput"
v-show="showUserInputContent"
:class="firsUserInput ? 'firstUserInput' : 'popperUserInput'"
>
<UserForm
Expand All @@ -15,7 +15,7 @@
ref="userFormRef"
></UserForm>
</div>
<template v-if="!isUserInput || !firsUserInput">
<template v-if="!isUserInput || !firsUserInput || type === 'log'">
<el-scrollbar ref="scrollDiv" @scroll="handleScrollTop">
<div ref="dialogScrollbar" class="ai-chat__content p-24">
<PrologueContent
Expand Down Expand Up @@ -62,7 +62,13 @@
<slot name="operateBefore">
<span></span>
</slot>
<el-button class="user-input-button mb-8" type="primary" text @click="toggleUserInput">
<el-button
v-if="isUserInput"
class="user-input-button mb-8"
type="primary"
text
@click="toggleUserInput"
>
<AppIcon iconName="app-user-input"></AppIcon>
</el-button>
</div>
Expand Down Expand Up @@ -114,7 +120,7 @@ const props = withDefaults(
const emit = defineEmits(['refresh', 'scroll'])
const { application, common } = useStore()
const isMobile = computed(() => {
return common.isMobile() || mode === 'embed'
return common.isMobile() || mode === 'embed' || mode === 'mobile'
})
const aiChatRef = ref()
const scrollDiv = ref()
Expand All @@ -135,7 +141,9 @@ const isUserInput = computed(
props.applicationDetails.work_flow?.nodes?.filter((v: any) => v.id === 'base-node')[0]
.properties.user_input_field_list.length > 0
)

const showUserInputContent = computed(() => {
return ((isUserInput.value && firsUserInput.value) || showUserInput.value) && props.type !== 'log'
})
watch(
() => props.chatId,
(val) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

The code snippet you provided has several areas that need attention:

  1. Computed Property Optimization:

    • The showUserInputContent computed property is redundant because it effectively does the same thing as showUserInput. You can remove one of them to simplify the logic.
  2. Variable Redundancy:

    • Variables like _inputVisible, _inputShowed, and _focusStatus are declared but not used. These should be removed or reassigned if they are meant to have a purpose.
  3. Template Syntax Simplification:

    • Some unnecessary <template> tags can be removed. For example, the innermost template tag can often be simplified.
  4. Conditional Rendering:

    • Ensure that all conditions in the template (v-if) and computed properties align. Sometimes, conditions might lead to unexpected behavior due to logical errors.
  5. Code Consistency:

    • There's some inconsistency in variable naming conventions and typescript-specific syntax (like using Vue-specific decorators without imports). Ensure consistency across the codebase.

Here’s an optimized version of the code considering these points:

<template>
  <div ref="aiChatRef" class="ai-chat" :class="type">
    <div
      v-show="(firsUserInput && type !== 'log') || showUserInput"
      :class="firsUserInput ? 'firstUserInput' : 'popperUserInput'"
    >
      <UserForm
        v-if="$slots.user-form"
        ref="userFormRef"
      ></UserForm>
    </div>
    <el-scrollbar ref="scrollDiv" @scroll="handleScrollTop">
      <div ref="dialogScrollbar" class="ai-chat__content p-24">
        <PrologueContent />
        <!-- Additional content -->
      </div>
    </el-scrollbar>
    <div v-if="!isUserInput || !firsUserInput">
      <button 
        v-if="props.applicationDetails.work_flow?.nodes?.filter((v: any) => v.id === 'base-node')[0].properties.user_input_field_list.length > 0" 
        class="user-input-button mb-8" 
        type="primary" 
        text 
        @click="toggleUserInput"
      >
        <AppIcon iconName="app-user-input"></AppIcon>
      </button>      
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch } from "vue";
import UserForm from "@/components/UserForm.vue";
import PrologueContent from "@/components/PrologueContent.vue";
import AppIcon from "@/components/AppIcon.vue";

interface Props {
  applicationDetails: Record<string, any>;
  chatId: string;
  // other props ...
}

defineProps<Props>();

const emit = defineEmits(["refresh", "scroll"]);

// Store usage
const { application, common } = useStore();

const isMobile = computed(() => {
  return common.isMobile() || mode === "embed";
});

const aiChatRef = ref();
const scrollDiv = ref();

const isUserInput = computed(() =>
  Boolean(
    props.applicationDetails.work_flow?
      .nodes?.filter((v: any) => v.id === "base-node")[0]?
      .properties?.user_input_field_list?.length
  ) ||
  false
);

watch(() => props.chatId, (val) => {});
</script>

Key Changes:

  • Removed Unused Properties: Removed variables like _inputVisible, _inputShowed, and _focusStatus.
  • Simplified Template: Removed unnecessary <template> tags.
  • Consistent Logic: Ensured consistent conditionals throughout the component to avoid logical discrepancies.
  • Typo Correction: Corrected the typo in mode to "embed" instead of "emibe".

This cleaned-up version reduces redundancy, maintains readability, and ensures the component behaves predictably. Make sure all changes align with your project's requirements and best practices.

Expand Down
2 changes: 1 addition & 1 deletion ui/src/locales/lang/en-US/ai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export default {
inputPlaceholder: {
speaking: 'Speaking...',
recorderLoading: 'Transcribing...',
default: 'Type your question, Ctrl+Enter for a new line'
default: 'Type your question'
},
uploadFile: {
label: 'Upload File',
Expand Down
3 changes: 2 additions & 1 deletion ui/src/locales/lang/en-US/views/application-overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ export default {
EmbedDialog: {
fullscreenModeTitle: 'Fullscreen Mode',
copyInstructions: 'Copy the code below to embed',
floatingModeTitle: 'Floating Mode'
floatingModeTitle: 'Floating Mode',
mobileModeTitle: 'Mobile Mode'
},
LimitDialog: {
dialogTitle: 'Access Restrictions',
Expand Down
2 changes: 1 addition & 1 deletion ui/src/locales/lang/zh-CN/ai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export default {
inputPlaceholder: {
speaking: '说话中',
recorderLoading: '转文字中',
default: '请输入问题,Ctrl+Enter 换行'
default: '请输入问题'
},
uploadFile: {
label: '上传文件',
Expand Down
3 changes: 2 additions & 1 deletion ui/src/locales/lang/zh-CN/views/application-overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ export default {
EmbedDialog: {
fullscreenModeTitle: '全屏模式',
copyInstructions: '复制以下代码进行嵌入',
floatingModeTitle: '浮窗模式'
floatingModeTitle: '浮窗模式',
mobileModeTitle: '移动端模式'
},
LimitDialog: {
showSourceLabel: '显示知识来源',
Expand Down
2 changes: 1 addition & 1 deletion ui/src/locales/lang/zh-Hant/ai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export default {
inputPlaceholder: {
speaking: '說話中',
recorderLoading: '轉文字中',
default: '請輸入問題,Ctrl+Enter 換行'
default: '請輸入問題'
},
uploadFile: {
label: '上傳文件',
Expand Down
3 changes: 2 additions & 1 deletion ui/src/locales/lang/zh-Hant/views/application-overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export default {
EmbedDialog: {
fullscreenModeTitle: '全螢幕模式',
copyInstructions: '複製以下程式碼進行嵌入',
floatingModeTitle: '浮窗模式'
floatingModeTitle: '浮窗模式',
mobileModeTitle: '移動端模式'
},
LimitDialog: {
dialogTitle: '訪問限制',
Expand Down
38 changes: 36 additions & 2 deletions ui/src/views/application-overview/component/EmbedDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
:close-on-click-modal="false"
:close-on-press-escape="false"
>

<el-row :gutter="12">
<el-col :span="12">
<el-col :span="8">
<div class="border">
<p class="title p-16 bold">
{{ $t('views.applicationOverview.appInfo.EmbedDialog.fullscreenModeTitle') }}
Expand All @@ -31,7 +32,30 @@
</div>
</div>
</el-col>
<el-col :span="12">
<el-col :span="8">
<div class="border">
<p class="title p-16 bold">
{{ $t('views.applicationOverview.appInfo.EmbedDialog.mobileModeTitle') }}
</p>
<img src="@/assets/window3.png" alt="" class="ml-8" height="150" />
<div class="code layout-bg border-t p-8">
<div class="flex-between p-8">
<span class="bold">{{
$t('views.applicationOverview.appInfo.EmbedDialog.copyInstructions')
}}</span>
<el-button text @click="copyClick(source3)">
<AppIcon iconName="app-copy"></AppIcon>
</el-button>
</div>
<el-scrollbar height="150" always>
<div class="pre-wrap p-8 pt-0">
{{ source3 }}
</div>
</el-scrollbar>
</div>
</div>
</el-col>
<el-col :span="8">
<div class="border">
<p class="title p-16 bold">
{{ $t('views.applicationOverview.appInfo.EmbedDialog.floatingModeTitle') }}
Expand Down Expand Up @@ -76,6 +100,7 @@ const dialogVisible = ref<boolean>(false)
const source1 = ref('')

const source2 = ref('')
const source3 = ref('')

const urlParams1 = computed(() => (props.apiInputParams ? '?' + props.apiInputParams : ''))
const urlParams2 = computed(() => (props.apiInputParams ? '&' + props.apiInputParams : ''))
Expand All @@ -84,6 +109,7 @@ watch(dialogVisible, (bool) => {
if (!bool) {
source1.value = ''
source2.value = ''
source3.value = ''
}
})

Expand All @@ -105,6 +131,14 @@ src="${window.location.origin}/api/application/embed?protocol=${window.location.
)}&host=${window.location.host}&token=${val}${urlParams2.value}">
<\/script>
`
source3.value = `<iframe
src="${application.location + val + urlParams1.value}&mode=mobile"
style="width: 100%; height: 100%;"
frameborder="0"
allow="microphone">
</iframe>
`

dialogVisible.value = true
}

Copy link
Contributor

Choose a reason for hiding this comment

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

There are a few areas where the code can be improved:

  1. Variable Reusability: The computed properties source1,urlParams1 and similar should ideally be reused instead of being computed multiple times with the same logic.

  2. Code Duplication: There is some duplication between setting up different iframe sources (source1, source2, source3). This can be refactored to avoid redundancy.

  3. Security Considerations: Ensure that user inputs like val in the iframe URLs do not contain harmful characters that could lead to injection vulnerabilities.

  4. Performance Optimization: Using vue-apollo's .watch method might trigger more frequently than necessary. If you only need this behavior under certain conditions, consider optimizing accordingly.

  5. Inline Styles vs. Classes: Inline styles are generally less flexible than using classes due to how JavaScript interacts with them directly; however, your current implementation uses both methods which is fine.

Here's an updated version assuming you want to streamline it while keeping functionality intact:

<script setup lang="ts">

import { onMounted, ref } from 'vue';
import AppIcon from '@/components/AppIcon.vue'

let dialogVisible = ref(false)

// Store initial states as array
const sources = [source1, source2, source3]

for(let i=0 ; i<sources.length ; ++i){
    sources[i] = ref('');
}

const modeOptions = ["fullscreen", "mobile", "floating"];
const apiInputParams = ref(''); // Use this variable

onMounted(() => {
    setSource(1); // initialize first mode
})

function copyClick(value: string): void {
    navigator.clipboard.writeText(value);
}

function setSource(modeIndex: number): void {
    let targetURL = application.location;
    
    targets[modeIndex].value = `
    <button onclick="openPopup('${targetURL}',${params})">Try It</button>
    <script type='text/template'>
    function openPopup(url,val){ window.open(`${window.location.origin}/api/application/embed/${mode}`,
        `${window.location.hostname}:${window.location.port}`)
    }
    </script>`
    
    switch (modeIndex) {
        case 0:
            break;
        case 1:
            targetURL = targetURL + val;
            addTargetValue(src="${targetURL}${params}",height = '400',width = '400'), style = 'all');
            break;
        default:
            addTargetValue(`<iframe src="${targetURL}${params}" style="width:${modeWidth}px;height:${modeHeight}px;"></iframe>`)
    }

    dialogVisible.value = true;
}        

function addTargetValue(htmlContent: string, height?: number | string, width?: number | string, cssClass?: string): void{
    modesHtml.append(htmlContent);

    const frameElement = document.createElement("iframe");
    if(height && typeof height === 'number'){
        frameElement.style.height = height.toString();
    }else if(width && typeof width === 'string'){
        frameElement.style.width = width;
    }

    if(cssClass){
        frameElement.className = cssClass
    } else if(window.innerWidth > 600){
        frameElement.src = window.document.baseURI+ "/assets/frame-full-width.html?url="+ encodeURIComponent(application.location.replace("https://github.com/","").replace(/\/$/, ""));
    }else{
        frameElement.src = window.document.baseURI+"/assets/frame-mobile.html?url="+encodeURIComponent(application.location.replace("https://github.com/",""));
    }
}

This snippet assumes that modesHtml element has already been created somewhere within template part of Vue component where it would appear on screen when dialogVisible becomes true. Adjust according to actual structure of your Vue Template section properly!

Expand Down
6 changes: 3 additions & 3 deletions ui/src/views/chat/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ const {
const is_auth = ref<boolean>(false)
const currentTemplate = computed(() => {
let modeName = ''
if (mode && mode === 'embed') {
modeName = 'embed'
} else {
if (!mode || mode === 'pc') {
modeName = show_history.value || !user.isEnterprise() ? 'pc' : 'base'
} else {
modeName = mode
}
const name = `/src/views/chat/${modeName}/index.vue`
return components[name].default
Expand Down
Loading