Skip to content
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
2 changes: 2 additions & 0 deletions ui/src/conversation-panel/components/content/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { computed, type Component } from 'vue'
import TextItem from './items/text.vue'
import HtmlItem from './items/html.vue'
import EchartsItem from './items/echarts.vue'
import ReasoningItem from './items/reasoning.vue'
import ToolItem from './items/tool/index.vue'
import GroupToolItem from './items/group-tool/index.vue'
Expand All @@ -19,6 +20,7 @@ const props = defineProps<{
const typeRenderers: Partial<Record<string, Component>> = {
TEXT: TextItem,
HTML: HtmlItem,
ECHARTS: EchartsItem,
REASONING: ReasoningItem,
TOOL: ToolItem,
GROUP_TOOL: GroupToolItem,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as echarts from 'echarts'
import { randomId } from '@/utils/common'

defineOptions({ name: 'MarkdownEchartsRender' })
defineOptions({ name: 'ContentEchartsItem' })

const props = defineProps<{ option: string }>()
const props = defineProps<{
// content 为图表配置 JSON 字符串
content: { content?: string | null }
}>()
const optionSource = computed(() => props.content.content ?? '')

// 后端下发的图表配置:actionType 为 EVAL 时 option 是一段脚本,需在沙箱 iframe 中执行得到真实 option
type ChartStyle = Record<string, string>
Expand Down Expand Up @@ -114,9 +118,9 @@ function applyOption(payload: ChartOptionPayload & echarts.EChartsOption) {
}

function initChart() {
if (!chartsRef.value || !props.option) return
if (!chartsRef.value || !optionSource.value) return
try {
applyOption(JSON.parse(props.option))
applyOption(JSON.parse(optionSource.value))
} catch (e) {
console.error('[ECharts] invalid option JSON', e)
}
Expand All @@ -132,12 +136,9 @@ function onResize() {
}, 100)
}

watch(
() => props.option,
(option) => {
if (option) nextTick(initChart)
},
)
watch(optionSource, (value) => {
if (value) nextTick(initChart)
})

onMounted(() => {
nextTick(initChart)
Expand Down
20 changes: 12 additions & 8 deletions ui/src/conversation-panel/components/content/items/form.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import { computed, ref } from 'vue'
import { MkDynamicsForm } from '@/components/mk-dynamics-form'
import type { DynamicFormValue, FormField } from '@/components/mk-dynamics-form'
import type { Dict } from '@/api/types'
import { useMessageListStoreOptional } from '@/conversation-panel/components/message-list/index'

import { useSendMessageOptional } from '@/conversation-panel/components/message-list/index'
import { useViewType, VIEW_TYPE, type ViewType } from "@/conversation-panel/view/index"
defineOptions({ name: 'ConversationFormContent' })

// message-list store(由 view provide);拿不到发送能力 → 只读降级
const mainStore = useMessageListStoreOptional()
const viewType = useViewType()
// 发送能力由消息列表或外部宿主 provide;拿不到 → 只读降级
const sendMessage = useSendMessageOptional()

const props = defineProps<{
content: {
Expand Down Expand Up @@ -42,7 +42,7 @@ const submit = async () => {
try {
await dynamicsFormRef.value?.validate()
_submit.value = true
mainStore?.sendMessage({
sendMessage.value?.({
message: { content: '', type: 'QUESTION' },
newQuestion: false,
reChat: true,
Expand All @@ -56,14 +56,18 @@ const submit = async () => {
_submit.value = false
}
}
const READONLY_VIEWS: readonly ViewType[] = [VIEW_TYPE.LOG]

</script>

<template>
<div class="mk-gray-card-lg space-y-4 border border-N300">
<MdPreview class="!h-auto" editorId="conversation-text" :modelValue="leadingText || ''"></MdPreview>
<MkDynamicsForm ref="dynamicsFormRef" :view="is_submit || !mainStore" :render-data="form_field_list" v-model="form_data" />
<MkDynamicsForm ref="dynamicsFormRef" :view="is_submit || READONLY_VIEWS.includes(viewType)"
:render-data="form_field_list" v-model="form_data" />
<MdPreview class="!h-auto" editorId="conversation-text" :modelValue="trailingText || ''"></MdPreview>
<el-button :type="is_submit ? 'info' : 'primary'" :disabled="is_submit || !mainStore" @click="submit">
<el-button :type="is_submit ? 'info' : 'primary'"
:disabled="is_submit || READONLY_VIEWS.includes(viewType) || !sendMessage" @click="submit">
{{ is_submit ? '已提交' : '提交' }}
</el-button>
</div>
Expand Down
26 changes: 19 additions & 7 deletions ui/src/conversation-panel/components/content/items/html.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
<script setup lang="ts">
import { computed } from 'vue'
import DOMPurify from 'dompurify'
import { computed, ref } from 'vue'
import { useIframeBridge } from '../../markdown-renderer/iframe-bridge'
import { useSendQuestionOptional } from '../../message-input/index'
import { useChatUserProfileGetter } from '../../../view/index'

defineOptions({ name: 'ContentHtmlItem' })

const props = defineProps<{
content: { content?: string }
content: { content?: string | null }
}>()

// 回复内容可能引用用户输入,渲染前净化,去除脚本与事件属性
const sanitizedHtml = computed(() => DOMPurify.sanitize(props.content.content || ''))
// 日志等只读视图拿不到发送能力,iframe 内 sendMessage 随之失效
const sendQuestion = useSendQuestionOptional()
// 仅 Chat 入口提供,Admin 调试、日志中 iframe 内 chatUserProfile() 拿到 null
const getChatUserProfile = useChatUserProfileGetter()

// 回复 HTML 在沙箱 iframe 中渲染,脚本与父页面隔离
const iframeRef = ref<HTMLIFrameElement>()
const { buildSrcdoc } = useIframeBridge(iframeRef, {
extended: true,
sendMessage: (question, otherParams) => sendQuestion?.(question, otherParams),
getChatUserProfile,
})
const srcdoc = computed(() => buildSrcdoc(props.content.content ?? ''))
</script>

<template>
<!-- eslint-disable-next-line vue/no-v-html -- 内容已经 DOMPurify 净化 -->
<div class="overflow-x-auto [word-break:break-word]" v-html="sanitizedHtml" />
<iframe ref="iframeRef" class="w-full border-0" :srcdoc="srcdoc" allow="geolocation" sandbox="allow-scripts" />
</template>

This file was deleted.

26 changes: 8 additions & 18 deletions ui/src/conversation-panel/components/markdown-renderer/index.vue
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
<script setup lang="ts">
import { computed, useId, type Component } from 'vue'
import { pick } from 'lodash'
import type { Dict } from '@/api/types'
import loadErrorImage from '@/assets/conversation/load_error.png'
import { useMessageInputStoreOptional, type QuestionOptions } from '../message-input/index'
import { useSendQuestionOptional } from '../message-input/index'
import { parseRenderNodes, type RenderTag } from './parse-tags'
import QuickQuestion from './QuickQuestion.vue'
import HtmlRender from './HtmlRender.vue'
import IframeRender from './IframeRender.vue'
import EchartsRender from './EchartsRender.vue'
import FormRender from './FormRender.vue'
import ReasoningRender from './ReasoningRender.vue'
import ToolCallsRender from './tool-calls-render/index.vue'
import HtmlItem from '../content/items/html.vue'
import EchartsItem from '../content/items/echarts.vue'

defineOptions({ name: 'MarkdownRenderer' })

Expand All @@ -26,18 +25,8 @@ const props = withDefaults(
const editorIdPrefix = `markdown-renderer-${useId()}`
const renderNodes = computed(() => parseRenderNodes(props.source))

// 发送走 message-input:新对话时由它生成会话并同步标题。日志等只读视图不注入,标签内的发送入口随之禁用
const messageInputStore = useMessageInputStoreOptional()
const ATTACHMENT_FIELDS = ['image_list', 'document_list', 'audio_list', 'video_list', 'other_list']

// otherParams 为 v2 的 other_params_data:附件列表与 form_data
function sendQuestion(question: string, otherParams?: Dict<unknown>) {
messageInputStore?.sendQuestion(question, {
attachments: pick(otherParams, ATTACHMENT_FIELDS) as QuestionOptions['attachments'],
formData: otherParams?.form_data as Dict<unknown> | undefined,
})
}
const sendMessage = messageInputStore ? sendQuestion : undefined
// 日志等只读视图拿不到发送能力,标签内的发送入口随之禁用
const sendMessage = useSendQuestionOptional() ?? undefined

interface TagRenderer {
component: Component
Expand All @@ -46,9 +35,10 @@ interface TagRenderer {

const tagRenderers: Record<RenderTag, TagRenderer> = {
quick_question: { component: QuickQuestion, props: (question) => ({ question, sendMessage }) },
html_rander: { component: HtmlRender, props: (source) => ({ source, sendMessage }) },
// HTML、图表标签与同类型内容项共用渲染
html_rander: { component: HtmlItem, props: (source) => ({ content: { content: source } }) },
iframe_render: { component: IframeRender, props: (source) => ({ source, sendMessage }) },
echarts_rander: { component: EchartsRender, props: (option) => ({ option }) },
echarts_rander: { component: EchartsItem, props: (option) => ({ content: { content: option } }) },
tool_calls_render: { component: ToolCallsRender, props: (content) => ({ content }) },
form_rander: { component: FormRender, props: (formSetting) => ({ formSetting }) },
}
Expand Down
17 changes: 17 additions & 0 deletions ui/src/conversation-panel/components/message-input/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ref, computed, reactive, watch, inject, type Ref, type InjectionKey } from 'vue'
import { pick } from 'lodash'
import { IMAGE_EXTENSIONS, DOCUMENT_EXTENSIONS, VIDEO_EXTENSIONS, AUDIO_EXTENSIONS } from '@/constants/file-type'
import { isImage, isDocument, isAudio, isVideo } from '@/utils/icon'
import { MsgError } from '@/utils/message'
Expand Down Expand Up @@ -284,3 +285,19 @@ export function useMessageInputStore(): MessageInputStore {
export function useMessageInputStoreOptional(): MessageInputStore | null {
return inject(MESSAGE_INPUT_KEY, null)
}

const ATTACHMENT_FIELDS = ['image_list', 'document_list', 'audio_list', 'video_list', 'other_list']

/**
* 回复内容(快捷问题、HTML、iframe)内的提问入口:otherParams 为 v2 的 other_params_data(附件列表与 form_data)。
* 发送走 message-input,新对话时由它生成会话并同步标题;日志等只读视图拿不到 store,返回 null。
*/
export function useSendQuestionOptional(): ((question: string, otherParams?: Dict<unknown>) => void) | null {
const store = useMessageInputStoreOptional()
if (!store) return null
return (question, otherParams) =>
store.sendQuestion(question, {
attachments: pick(otherParams, ATTACHMENT_FIELDS) as QuestionOptions['attachments'],
formData: otherParams?.form_data as Dict<unknown> | undefined,
})
}
12 changes: 9 additions & 3 deletions ui/src/conversation-panel/components/message-list/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,13 @@ export function useMessageListStore(): MessageListStore {
return store
}

// 供“只显示”等场景可选注入:拿不到返回 null(如 form 在无发送能力时降级只读)
export function useMessageListStoreOptional(): MessageListStore | null {
return inject(MESSAGE_LIST_KEY, null)
// 通用发送能力,由消息列表或外部宿主(如工具调试结果抽屉)提供;内容项按自身场景组装参数。
export type SendMessage = (opts: SendMessageOptions) => void

// 值为 null 表示当前不可发送(分享、执行中等),由提供方按自身状态决定
export const SEND_MESSAGE_KEY: InjectionKey<Readonly<Ref<SendMessage | null>>> = Symbol('send-message')

// 未提供时恒为 null,内容项降级只读
export function useSendMessageOptional(): Readonly<Ref<SendMessage | null>> {
return inject(SEND_MESSAGE_KEY, ref(null))
}
8 changes: 5 additions & 3 deletions ui/src/conversation-panel/components/message-list/index.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
<script setup lang="ts">
import { computed, ref, onBeforeUnmount } from 'vue'
import { computed, ref, onBeforeUnmount, provide } from 'vue'
import ContentList from '../content-list/index.vue'
import MessageFooter from '../message-footer/index.vue'
import PrologueContent from '../prologue-content/index.vue'
import { formatPrologue } from '../prologue-content/index'
import ReverseInfiniteScroll from '../../core/reverse-infinite-scroll/index.vue'
import { useMessageListStore } from './index'
import { useMessageListStore, SEND_MESSAGE_KEY } from './index'
import ShareMessageRow from './share/ShareMessageRow.vue'
import ShareFooter from './share/ShareFooter.vue'
import { useConversationListStore } from '../../left-sidebar/conversation-list/index'
defineProps<{ appName?: string }>()

const { appInfo, messages, loading, streamLoading, hasMore, loadMore, onStreamProgress } = useMessageListStore()
const { appInfo, messages, loading, streamLoading, hasMore, loadMore, onStreamProgress, sendMessage } = useMessageListStore()
const { isShared } = useConversationListStore()
// 消息内容项(如表单)经此发起续跑;分享时收回发送能力,内容项只读
provide(SEND_MESSAGE_KEY, computed(() => (isShared.value ? null : sendMessage)))
// 分享按完整问答轮次提交,提问与回答共用记录 ID。
const selectedRecordIds = ref<string[]>([])
const messageRecordIds = computed(() =>
Expand Down
10 changes: 10 additions & 0 deletions ui/src/conversation-panel/view/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { createMessageInputStore } from '../../components/message-input/index'
import { createExecutionDetailStore } from '../../right-sidebar/execution-detail/index'
import { createSharePanelStore, type SharePanelStore } from '../../main/share-panel/index'
import type { ConversationBundle } from '../types'
import type { GetChatUserProfile } from '../index'
import { APPLICATION_DISPLAY_SETTING_DEFAULT } from '@/constants/application'
/** chat 模式在通用组件 store 之外提供分享;debug 模式无分享。 */
export interface ChatConversationBundle extends ConversationBundle {
Expand Down Expand Up @@ -46,6 +47,15 @@ function useApplicationChatRouting(): ChatConversationRouting {
}
}

/** 当前对话用户档案:未加载时按需加载;无用户名(非登录认证)或加载失败返回 null。 */
export function createChatUserProfileGetter(): GetChatUserProfile {
const { user } = useStore()
return async () => {
const profile = user.chatUserProfile ?? (await user.loadCurrentUser().catch(() => null))
return profile?.username ? profile : null
}
}

/**
* chat 模式:装配各组件 store。模式差异只在这里的 adapter。
* 当前会话与地址双向同步,切换智能体时重新加载智能体信息与会话。
Expand Down
8 changes: 7 additions & 1 deletion ui/src/conversation-panel/view/chat/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import { MESSAGE_LIST_KEY } from '../../components/message-list/index'
import { MESSAGE_INPUT_KEY } from '../../components/message-input/index'
import { EXECUTION_DETAIL_KEY } from '../../right-sidebar/execution-detail/index'
import { SHARE_PANEL_KEY, SHARE_PANEL_CONTENT_KEY } from '../../main/share-panel/index'
import { createChatConversation } from './index'
import { createChatConversation, createChatUserProfileGetter } from './index'
import { VIEW_KEY, VIEW_TYPE, DEVICE_KEY, CHAT_USER_PROFILE_KEY, createDeviceType } from '../index.ts'

const bundle = createChatConversation()

Expand Down Expand Up @@ -41,13 +42,18 @@ provide(SHARE_PANEL_CONTENT_KEY, {
...bundle.msgs,
title: computed(() => bundle.list.currentConversation.value?.abstract || '新建对话'),
})
provide(VIEW_KEY,VIEW_TYPE.CHAT)
// HTML 内容 iframe 内 chatUserProfile() 取当前对话用户
provide(CHAT_USER_PROFILE_KEY, createChatUserProfileGetter())

// 响应式:窄屏 drawer,宽屏 push
const BREAKPOINT = 768
const isMobile = ref(window.innerWidth < BREAKPOINT)
const onResize = () => (isMobile.value = window.innerWidth < BREAKPOINT)
const leftMode = computed(() => (isMobile.value ? 'drawer' : 'push'))
const rightMode = computed(() => (isMobile.value ? 'drawer' : 'push'))
// 左栏为抽屉即手机端
provide(DEVICE_KEY, createDeviceType(leftMode))

const closeDrawers = () => {
if (leftMode.value === 'drawer') bundle.list.leftSideOpen.value = false
Expand Down
5 changes: 4 additions & 1 deletion ui/src/conversation-panel/view/debug/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { MESSAGE_INPUT_KEY } from '../../components/message-input/index'
import { EXECUTION_DETAIL_KEY } from '../../right-sidebar/execution-detail/index'
import { createDebugHeaderStore, DEBUG_HEADER_KEY } from '../../components/header/debug/index'
import { createDebugConversation } from './index'
import { VIEW_KEY, VIEW_TYPE, DEVICE_KEY, createDeviceType } from '../index.ts'

defineOptions({ name: 'DebugConversation' })

Expand Down Expand Up @@ -44,7 +45,7 @@ provide(CONVERSATION_LIST_KEY, bundle.list)
provide(MESSAGE_LIST_KEY, bundle.msgs)
provide(MESSAGE_INPUT_KEY, bundle.input)
provide(EXECUTION_DETAIL_KEY, bundle.detail)

provide(VIEW_KEY,VIEW_TYPE.DEBUG)
const debug = createDebugHeaderStore()
provide(DEBUG_HEADER_KEY, debug)
// open/close 暴露给工作流页通过 ref 调用
Expand All @@ -53,6 +54,8 @@ defineExpose({ open: debug.open, close: debug.close })
// 缩小(窄面板)用抽屉,放大(宽面板)用挤压——与窗口宽度无关,只看放大态
const leftMode = computed(() => (debug.expanded.value ? 'push' : 'drawer'))
const rightMode = computed(() => (debug.expanded.value ? 'push' : 'drawer'))
// 左栏为抽屉即手机端
provide(DEVICE_KEY, createDeviceType(leftMode))

// 抽屉态:遮罩点击 / 切换会话 → 收起
const closeDrawers = () => {
Expand Down
Loading
Loading