优化渲染结构

This commit is contained in:
锦恢 2025-05-09 18:53:15 +08:00
parent 4c0566b470
commit 68f45fedf7
15 changed files with 142 additions and 94 deletions

View File

@ -8,6 +8,9 @@
- 修复 bug流式传输进行 function calling 时,多工具的索引串流导致的 JSON Schema 反序列化失败 - 修复 bug流式传输进行 function calling 时,多工具的索引串流导致的 JSON Schema 反序列化失败
- 修复 bug大模型返回大量重复错误信息 - 修复 bug大模型返回大量重复错误信息
- 新特性:支持一次对话同时调用多个工具 - 新特性:支持一次对话同时调用多个工具
- UI优化代码高亮的滚动条
- 新特性resources/list 协议的内容点击就会直接渲染,无需二次发送
- 新特性resources prompts tools 的结果的 json 模式支持高亮
## [main] 0.0.7 ## [main] 0.0.7
- 优化页面布局,使得调试窗口可以显示更多内容 - 优化页面布局,使得调试窗口可以显示更多内容

View File

@ -0,0 +1,22 @@
<template>
<el-scrollbar width="100%">
<div v-html="renderJson(json)">
</div>
</el-scrollbar>
</template>
<script setup lang="ts">
import { computed, defineProps, PropType } from 'vue';
import { renderJson } from '../main-panel/chat/markdown/markdown';
const props = defineProps({
json: {
type: Object as PropType<string | object | undefined>,
required: true
}
});
</script>
<style>
</style>

View File

@ -7,7 +7,7 @@
<el-dialog v-model="showChooseResource" :title="t('resources')" width="400px"> <el-dialog v-model="showChooseResource" :title="t('resources')" width="400px">
<div class="resource-template-container-scrollbar" v-if="!selectResource"> <div class="resource-template-container-scrollbar" v-if="!selectResource">
<ResourceList :tab-id="-1" @resource-selected="resource => selectResource = resource" /> <ResourceList :tab-id="-1" @resource-selected="resource => handleResourceSelected(resource)" />
</div> </div>
<div v-else> <div v-else>
<ResourceReader :tab-id="-1" :current-resource-name="selectResource!.name" <ResourceReader :tab-id="-1" :current-resource-name="selectResource!.name"
@ -25,13 +25,14 @@
import { createApp, inject, ref } from 'vue'; import { createApp, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { ChatStorage, EditorContext } from '../chat'; import { ChatStorage, EditorContext } from '../chat';
import { ResourcesReadResponse, ResourceTemplate } from '@/hook/type'; import { Resources, ResourcesReadResponse, ResourceTemplate } from '@/hook/type';
import ResourceList from '@/components/main-panel/resource/resource-list.vue'; import ResourceList from '@/components/main-panel/resource/resource-list.vue';
import ResourceReader from '@/components/main-panel/resource/resouce-reader.vue'; import ResourceReader from '@/components/main-panel/resource/resouce-reader.vue';
import { ElMessage, ElTooltip, ElProgress, ElPopover } from 'element-plus'; import { ElMessage, ElTooltip, ElProgress, ElPopover } from 'element-plus';
import ResourceChatItem from '../resource-chat-item.vue'; import ResourceChatItem from '../resource-chat-item.vue';
import { useMessageBridge } from '@/api/message-bridge';
const { t } = useI18n(); const { t } = useI18n();
@ -57,6 +58,15 @@ function saveCursorPosition() {
} }
} }
async function handleResourceSelected(resource: Resources) {
selectResource.value = undefined;
const bridge = useMessageBridge();
const { code, msg } = await bridge.commandRequest('resources/read', { resourceUri: resource.uri });
if (msg) {
await whenGetResourceResponse(msg as ResourcesReadResponse);
}
}
async function whenGetResourceResponse(msg: ResourcesReadResponse) { async function whenGetResourceResponse(msg: ResourcesReadResponse) {
if (!msg) { if (!msg) {
return; return;

View File

@ -10,8 +10,19 @@ function escapeHtml(unsafe: string) {
.replace(/'/g, "&#039;"); .replace(/'/g, "&#039;");
} }
interface HighlightOption {
needTools?: boolean
}
// 导出默认的 highlight 函数 // 导出默认的 highlight 函数
export default function highlight(str: string, lang: string) { export default function highlight(option: HighlightOption = {}) {
const {
needTools = true
} = option;
return (str: string, lang: string) => {
if (needTools) {
// 创建代码块容器 // 创建代码块容器
let container = `<div class="openmcp-code-block">`; let container = `<div class="openmcp-code-block">`;
@ -35,8 +46,13 @@ export default function highlight(str: string, lang: string) {
container += `</div>`; container += `</div>`;
return container; return container;
} else {
return Prism.highlight(str, Prism.languages[lang], lang);
}
}
} }
// 全局复制函数 // 全局复制函数
(window as any).copyCode = function (button: HTMLElement) { (window as any).copyCode = function (button: HTMLElement) {
const codeBlock = button.closest('.openmcp-code-block'); const codeBlock = button.closest('.openmcp-code-block');

View File

@ -3,7 +3,7 @@ import MarkdownKatex from './markdown-katex';
import MarkdownHighlight from './markdown-highlight'; import MarkdownHighlight from './markdown-highlight';
const md = new MarkdownIt({ const md = new MarkdownIt({
highlight: MarkdownHighlight, highlight: MarkdownHighlight({ needTools: true }),
}); });
md.use(MarkdownKatex, { md.use(MarkdownKatex, {
@ -18,6 +18,35 @@ export const markdownToHtml = (markdown: string) => {
return md.render(markdown); return md.render(markdown);
}; };
const pureHighLightMd = new MarkdownIt({
highlight: MarkdownHighlight({ needTools: false }),
});
export const copyToClipboard = (text: string) => { export const copyToClipboard = (text: string) => {
return navigator.clipboard.writeText(text); return navigator.clipboard.writeText(text);
}; };
const tryParseJson = (text: string) => {
try {
return JSON.parse(text);
} catch (error) {
return text;
}
}
const prettifyObj = (obj: object | string) => {
const rawObj = typeof obj === 'string' ? tryParseJson(obj) : obj;
return JSON.stringify(rawObj, null, 2);
}
export const renderJson = (obj: object | string | undefined) => {
if (!obj) {
return '<span>Invalid JSON</span>';
}
const jsonString = prettifyObj(obj);
const md = "```json\n" + jsonString + "\n```";
const html = pureHighLightMd.render(md);
return html;
}

View File

@ -36,7 +36,6 @@
max-height: inherit; max-height: inherit;
height: inherit; height: inherit;
display: block; display: block;
overflow: auto;
background-color: unset !important; background-color: unset !important;
} }
@ -251,7 +250,6 @@
max-height: inherit; max-height: inherit;
height: inherit; height: inherit;
display: block; display: block;
overflow: auto;
background-color: unset !important; background-color: unset !important;
} }

View File

@ -1,5 +1,5 @@
<template> <template>
<el-scrollbar width="100%"> <el-scrollbar width="100%" max-height="300px">
<div v-if="props.item.type === 'text'" class="tool-text"> <div v-if="props.item.type === 'text'" class="tool-text">
{{ props.item.text }} {{ props.item.text }}
</div> </div>

View File

@ -7,8 +7,11 @@
</span> </span>
</div> </div>
<div class="message-text tool_calls" :class="[currentMessageLevel]"> <div class="message-text tool_calls" :class="[currentMessageLevel]">
<!-- 工具的消息 -->
<div v-if="props.message.content" v-html="markdownToHtml(props.message.content)"></div> <div v-if="props.message.content" v-html="markdownToHtml(props.message.content)"></div>
<!-- 工具的调用 -->
<el-collapse v-model="activeNames" v-if="props.message.tool_calls"> <el-collapse v-model="activeNames" v-if="props.message.tool_calls">
<el-collapse-item name="tool"> <el-collapse-item name="tool">
<template #title> <template #title>
@ -43,12 +46,7 @@
</div> </div>
<div class="tool-arguments"> <div class="tool-arguments">
<el-scrollbar width="100%"> <json-render :json="props.message.tool_calls[toolIndex].function.arguments"/>
<div class="inner">
<div v-html="jsonResultToHtml(props.message.tool_calls[toolIndex].function.arguments)">
</div>
</div>
</el-scrollbar>
</div> </div>
<!-- 工具调用结果 --> <!-- 工具调用结果 -->
@ -79,9 +77,7 @@
<div class="tool-result" v-if="isValid(toolResult)"> <div class="tool-result" v-if="isValid(toolResult)">
<!-- 展示 JSON --> <!-- 展示 JSON -->
<div v-if="props.message.showJson!.value" class="tool-result-content"> <div v-if="props.message.showJson!.value" class="tool-result-content">
<div class="inner"> <json-render :json="props.message.toolResults[toolIndex]"/>
<div v-html="toHtml(props.message.toolResults[toolIndex])"></div>
</div>
</div> </div>
<!-- 展示富文本 --> <!-- 展示富文本 -->
@ -120,6 +116,7 @@
</div> </div>
</el-collapse-item> </el-collapse-item>
</el-collapse> </el-collapse>
</div> </div>
</template> </template>
@ -134,9 +131,10 @@ import { IToolRenderMessage, MessageState } from '../chat-box/chat';
import { ToolCallContent } from '@/hook/type'; import { ToolCallContent } from '@/hook/type';
import ToolcallResultItem from './toolcall-result-item.vue'; import ToolcallResultItem from './toolcall-result-item.vue';
import JsonRender from '@/components/json-render/index.vue';
const { t } = useI18n(); const { t } = useI18n();
const props = defineProps({ const props = defineProps({
message: { message: {
type: Object as PropType<IToolRenderMessage>, type: Object as PropType<IToolRenderMessage>,
@ -149,7 +147,6 @@ const props = defineProps({
}); });
const hasOcr = computed(() => { const hasOcr = computed(() => {
if (props.message.role === 'assistant/tool_calls') { if (props.message.role === 'assistant/tool_calls') {
for (const toolResult of props.message.toolResults) { for (const toolResult of props.message.toolResults) {
for (const item of toolResult) { for (const item of toolResult) {
@ -198,26 +195,6 @@ function collposePanel() {
}, 1000); }, 1000);
} }
/**
* @description 将工具调用结果转换成 html
* @param toolResult
*/
const toHtml = (toolResult: ToolCallContent[]) => {
const formattedJson = JSON.stringify(toolResult, null, 2);
const html = markdownToHtml('```json\n' + formattedJson + '\n```');
return html;
};
const jsonResultToHtml = (jsonResult: string) => {
try {
const formattedJson = JSON.stringify(JSON.parse(jsonResult), null, 2);
const html = markdownToHtml('```json\n' + formattedJson + '\n```');
return html;
} catch (error) {
const html = markdownToHtml('```json\n' + jsonResult + '\n```');
return html;
}
}
function gotoIssue() { function gotoIssue() {
window.open('https://github.com/LSTM-Kirigaya/openmcp-client/issues', '_blank'); window.open('https://github.com/LSTM-Kirigaya/openmcp-client/issues', '_blank');

View File

@ -24,7 +24,7 @@
</span> </span>
</template> </template>
<template v-else> <template v-else>
{{ formattedJson }} <json-render :json="tabStorage.lastPromptGetResponse"/>
</template> </template>
</div> </div>
</el-scrollbar> </el-scrollbar>
@ -36,6 +36,7 @@ import { defineComponent, defineProps, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { tabs } from '../panel'; import { tabs } from '../panel';
import { PromptStorage } from './prompts'; import { PromptStorage } from './prompts';
import JsonRender from '@/components/json-render/index.vue';
defineComponent({ name: 'prompt-logger' }); defineComponent({ name: 'prompt-logger' });
const { t } = useI18n(); const { t } = useI18n();
@ -52,13 +53,6 @@ const tabStorage = tab.storage as PromptStorage;
const showRawJson = ref(false); const showRawJson = ref(false);
const formattedJson = computed(() => {
try {
return JSON.stringify(tabStorage.lastPromptGetResponse, null, 2);
} catch {
return 'Invalid JSON';
}
});
</script> </script>
<style> <style>

View File

@ -16,7 +16,7 @@
<el-switch v-else-if="param.type === 'boolean'" v-model="tabStorage.formData[param.name]" /> <el-switch v-else-if="param.type === 'boolean'" v-model="tabStorage.formData[param.name]" />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item v-if="tabStorage.currentType === 'template'">
<el-button type="primary" :loading="loading" @click="handleSubmit"> <el-button type="primary" :loading="loading" @click="handleSubmit">
{{ t('read-resource') }} {{ t('read-resource') }}
</el-button> </el-button>
@ -24,6 +24,11 @@
{{ t('reset') }} {{ t('reset') }}
</el-button> </el-button>
</el-form-item> </el-form-item>
<el-form-item v-else>
<el-button @click="handleSubmit">
{{ t("refresh") }}
</el-button>
</el-form-item>
</el-form> </el-form>
</div> </div>
</template> </template>
@ -142,9 +147,7 @@ function getUri() {
} }
const currentResourceName = props.tabId >= 0 ? tabStorage.currentResourceName : props.currentResourceName; const currentResourceName = props.tabId >= 0 ? tabStorage.currentResourceName : props.currentResourceName;
const targetResource = resourcesManager.resources.find(resources => resources.name === currentResourceName); const targetResource = resourcesManager.resources.find(resources => resources.name === currentResourceName);
return targetResource?.uri; return targetResource?.uri;
} }
@ -154,9 +157,7 @@ async function handleSubmit() {
const bridge = useMessageBridge(); const bridge = useMessageBridge();
const { code, msg } = await bridge.commandRequest('resources/read', { resourceUri: uri }); const { code, msg } = await bridge.commandRequest('resources/read', { resourceUri: uri });
tabStorage.lastResourceReadResponse = msg; tabStorage.lastResourceReadResponse = msg;
emits('resource-get-response', msg); emits('resource-get-response', msg);
} }

View File

@ -75,12 +75,18 @@ function reloadResources(option: { first: boolean }) {
} }
} }
function handleClick(resource: Resources) { async function handleClick(resource: Resources) {
tabStorage.currentType = 'resource'; tabStorage.currentType = 'resource';
tabStorage.currentResourceName = resource.name; tabStorage.currentResourceName = resource.name;
tabStorage.lastResourceReadResponse = undefined; tabStorage.lastResourceReadResponse = undefined;
emits('resource-selected', resource); emits('resource-selected', resource);
//
if (props.tabId >= 0) {
const bridge = useMessageBridge();
const { code, msg } = await bridge.commandRequest('resources/read', { resourceUri: resource.uri });
tabStorage.lastResourceReadResponse = msg;
}
} }
let commandCancel: (() => void); let commandCancel: (() => void);

View File

@ -37,7 +37,7 @@
</span> </span>
</template> </template>
<template v-else> <template v-else>
{{ formattedJson }} <json-render :json="tabStorage.lastResourceReadResponse"/>
</template> </template>
</div> </div>
</el-scrollbar> </el-scrollbar>
@ -50,6 +50,7 @@ import { useI18n } from 'vue-i18n';
import { tabs } from '../panel'; import { tabs } from '../panel';
import { ResourceStorage } from './resources'; import { ResourceStorage } from './resources';
import { getImageBlobUrlByBase64 } from '@/hook/util'; import { getImageBlobUrlByBase64 } from '@/hook/util';
import JsonRender from '@/components/json-render/index.vue';
defineComponent({ name: 'resource-logger' }); defineComponent({ name: 'resource-logger' });
const { t } = useI18n(); const { t } = useI18n();

View File

@ -26,7 +26,7 @@
<!-- 展示 json --> <!-- 展示 json -->
<template v-else> <template v-else>
{{ formattedJson }} <json-render :json="tabStorage.lastToolCallResponse"/>
</template> </template>
</div> </div>
@ -40,7 +40,7 @@ import { defineComponent, defineProps, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { tabs } from '../panel'; import { tabs } from '../panel';
import { ToolStorage } from './tools'; import { ToolStorage } from './tools';
import { useMessageBridge } from '@/api/message-bridge'; import JsonRender from '@/components/json-render/index.vue';
defineComponent({ name: 'tool-logger' }); defineComponent({ name: 'tool-logger' });
const { t } = useI18n(); const { t } = useI18n();
@ -57,17 +57,6 @@ const tabStorage = tab.storage as ToolStorage;
const showRawJson = ref(false); const showRawJson = ref(false);
const formattedJson = computed(() => {
try {
if (typeof tabStorage.lastToolCallResponse === 'string') {
return tabStorage.lastToolCallResponse;
}
return JSON.stringify(tabStorage.lastToolCallResponse, null, 2);
} catch {
return 'Invalid JSON';
}
});
</script> </script>
<style> <style>

View File

@ -31,6 +31,7 @@ export interface McpOptions {
// SSE 特定选项 // SSE 特定选项
url?: string; url?: string;
cwd?: string; cwd?: string;
env?: Record<string, string>;
// 通用客户端选项 // 通用客户端选项
clientName?: string; clientName?: string;
clientVersion?: string; clientVersion?: string;

View File

@ -42,7 +42,8 @@ export class McpClient {
command: this.options.command || '', command: this.options.command || '',
args: this.options.args || [], args: this.options.args || [],
cwd: this.options.cwd || process.cwd(), cwd: this.options.cwd || process.cwd(),
stderr: 'pipe' stderr: 'pipe',
env: this.options.env,
}); });
break; break;
@ -119,8 +120,8 @@ export class McpClient {
// 调用工具 // 调用工具
public async callTool(options: { name: string; arguments: Record<string, any>, callToolOption?: any }) { public async callTool(options: { name: string; arguments: Record<string, any>, callToolOption?: any }) {
const { callToolOption, ...methodArgs } = options; const { callToolOption, ...methodArgs } = options;
console.log('callToolOption', callToolOption);
return await this.client.callTool(methodArgs, undefined, callToolOption); return await this.client.callTool(methodArgs, undefined, callToolOption);
} }
} }