Commit 7809d525 authored by fushen's avatar fushen

Initial commit

parents
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
{
"recommendations": ["Vue.volar"]
}
# fusion_agent_web
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "fusion_agent_web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@vue/compiler-dom": "^3.5.17",
"@vue/runtime-dom": "^3.5.17",
"echarts": "^5.6.0",
"markdown-it": "^14.1.0",
"marked": "^16.0.0",
"vue": "^3.5.13",
"vue-router": "^4.5.1",
"vue3-sfc-loader": "^0.9.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.4",
"tailwindcss": "^3.4.1",
"vite": "^6.2.4",
"vite-plugin-vue-devtools": "^7.7.2"
}
}
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
<script setup>
import HelloWorld from './components/HelloWorld.vue'
import TheWelcome from './components/TheWelcome.vue'
</script>
<!-- src/App.vue -->
<template>
<router-view />
</template>
<style scoped>
</style>
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
@import './base.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
/* src/assets/theme.css */
:root {
--bg-color: white;
--text-color: black;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #111;
--text-color: #eee;
}
}
\ No newline at end of file
<script setup>
defineProps({
msg: {
type: String,
required: true,
},
})
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
You’ve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>
<script setup>
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vue’s
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener">Vue - Official</a>. If
you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
// src/main.js
import './assets/theme.css'
import './assets/main.css'
const app = createApp(App)
app.use(router)
app.mount('#app')
\ No newline at end of file
<template>
<div class="h-screen flex bg-white dark:bg-[#1e1e20] text-black dark:text-white select-none">
<!-- 左侧 会话列表栏 -->
<aside class="w-80 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-[#2c2c32] flex flex-col p-4">
<div class="flex items-center justify-between mb-2">
<div class="text-lg font-bold">会话列表</div>
<button @click="createSession" class="bg-blue-600 text-white px-2 rounded text-sm">➕ 新建</button>
</div>
<div class="space-y-2 overflow-y-auto flex-1">
<div
v-for="id in sessionList"
:key="id"
@click="connectSession(id)"
class="p-2 rounded cursor-pointer text-sm break-all"
:class="[
id === sessionId ? 'bg-blue-600 text-white' : 'bg-white dark:bg-[#3a3a3f] hover:bg-gray-100 dark:hover:bg-[#4a4a4f]',
'border border-gray-300 dark:border-gray-600'
]"
>
{{ id }}
</div>
</div>
</aside>
<!-- 右侧聊天区 -->
<section class="flex-1 flex flex-col">
<!-- 消息列表 -->
<div ref="messageBox" class="flex-1 overflow-y-auto p-6 space-y-4 bg-gray-100 dark:bg-[#33343a] font-mono text-sm">
<div
v-for="msg in messages"
:key="msg.stream_id"
:class="[
'max-w-[70%] p-3 rounded-lg whitespace-pre-wrap',
msg.think ? 'think-style' : '',
msg.role === 'user'
? 'ml-auto bg-green-600 text-white'
: msg.role === 'assistant'
? 'mr-auto bg-white dark:bg-[#222] text-black dark:text-white'
: msg.role === 'tool'
? 'mr-auto bg-gray-200 dark:bg-gray-800 text-gray-800 dark:text-gray-100'
: 'mx-auto bg-gray-300 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
]"
>
<template v-if="msg.role === 'user'">
<div class="flex items-center justify-end gap-2">
<span class="text-white">🙋‍♂️</span>
<b>你:</b>
</div>
<div>{{ msg.content }}</div>
</template>
<template v-else-if="msg.role === 'assistant'">
<div class="flex items-center justify-start gap-2 mb-1 text-gray-400 italic select-text">
<span>🤖</span>
<b>助手:</b>
</div>
<div>{{ msg.content }}</div>
<div v-if="msg.tool_calls" class="bg-[#222] text-[#eee] p-2 rounded mt-2 whitespace-pre-wrap font-normal text-xs">
🛠️ 工具调用:
<pre>{{ JSON.stringify(msg.tool_calls, null, 2) }}</pre>
</div>
</template>
<template v-else-if="msg.role === 'tool'">
<div class="text-gray-500 text-xs mb-1">🛠️ 工具结果:</div>
<pre class="whitespace-pre-wrap">{{ msg.content }}</pre>
</template>
<template v-else>
📢 <i>{{ msg.content }}</i>
</template>
</div>
</div>
<!-- 输入区 -->
<div class="p-4 border-t border-gray-300 dark:border-gray-700 bg-white dark:bg-[#222] flex gap-2">
<input
v-model="taskInput"
placeholder="请输入任务内容"
class="border rounded px-3 py-2 flex-grow dark:bg-[#3a3a3f] dark:text-white"
:disabled="status !== 'idle' && status !== 'completed'"
@keydown.enter="startTask"
/>
<button @click="startTask" :disabled="status !== 'idle' && status !== 'completed'" class="bg-green-600 text-white px-4 rounded">
执行任务
</button>
</div>
<!-- 状态提示 -->
<div class="p-2 text-center text-sm select-none" :class="status === 'completed' ? 'text-green-600' : 'text-blue-600'">
{{ status === 'completed' ? "✅ 任务完成" : status === 'idle' ? "🟢 空闲中,等待输入..." : "⏳ 等待任务或执行中..." }}
</div>
</section>
</div>
</template>
<script setup>
import { ref, nextTick, onUnmounted } from 'vue';
const sessionList = ref([]);
const sessionId = ref('');
const taskInput = ref('');
const messages = ref([]);
const status = ref('idle');
const messageBox = ref(null);
let ws = null;
const streamMap = new Map();
const streamMetaMap = new Map();
function createSession() {
const newId = crypto.randomUUID();
sessionList.value.unshift(newId); // 新建的会话在顶部
connectSession(newId);
}
function connectSession(id) {
if (ws) ws.close();
sessionId.value = id;
messages.value = [];
status.value = 'idle';
streamMap.clear();
streamMetaMap.clear();
ws = new WebSocket(`ws://127.0.0.1:4010/api/chat/ws/${id}`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'history' && Array.isArray(data.messages)) {
for (const msg of data.messages) handleStreamData(msg);
} else {
handleStreamData(data);
}
};
ws.onclose = () => status.value = 'idle';
ws.onerror = (e) => console.error('[WS] 错误:', e);
// 防止重复添加
if (!sessionList.value.includes(id)) {
sessionList.value.unshift(id);
}
}
function handleStreamData(data) {
const { stream_id, stream_group_id, type, think, role } = data;
if (!stream_id || !stream_group_id) return;
let meta = streamMetaMap.get(stream_id);
if (!meta) {
meta = {
stream_group_id,
think: !!think,
type,
done: false
};
streamMetaMap.set(stream_id, meta);
}
if (type === 'done') {
meta.done = true;
status.value = 'completed';
return;
}
let msgEntry = streamMap.get(stream_id);
if (!msgEntry) {
msgEntry = {
stream_id,
stream_group_id,
role: role || (type === 'tool_calls_result' ? 'tool' : 'assistant'),
content: '',
think: !!think
};
streamMap.set(stream_id, msgEntry);
messages.value.push(msgEntry);
}
if (type === 'content') {
msgEntry.content += data.text || '';
} else if (type === 'tool_calls') {
msgEntry.tool_calls = data.info || null;
} else if (type === 'tool_calls_result') {
msgEntry.content = data.info || '';
}
nextTick(() => {
if (messageBox.value) {
messageBox.value.scrollTop = messageBox.value.scrollHeight;
}
});
messages.value = [...messages.value];
}
function startTask() {
if (ws && taskInput.value.trim()) {
const input = taskInput.value.trim();
messages.value.push({ role: 'user', content: input });
ws.send(JSON.stringify({ action: 'start', content: input }));
taskInput.value = '';
status.value = 'running';
}
}
onUnmounted(() => {
if (ws) ws.close();
});
</script>
<style>
.think-style {
opacity: 0.6;
font-style: italic;
background-color: #282828 !important;
color: #888 !important;
user-select: none;
pointer-events: none;
}
.dark .think-style {
background-color: #3a3a3a !important;
color: #999 !important;
}
</style>
<template>
<div class="flex flex-col flex-1 overflow-hidden">
<MessageList :messages="messages" @selectToolCall="$emit('selectToolCall', $event)" />
<InputBox
:disabled="status !== 'idle' && status !== 'completed'"
@send="onSendTask"
/>
<StatusBar :status="status" />
</div>
</template>
<script setup>
import MessageList from './MessageList.vue';
import InputBox from './InputBox.vue';
import StatusBar from './StatusBar.vue';
defineProps({
messages: Array,
status: String,
});
const emit = defineEmits(['sendTask', 'selectToolCall']);
function onSendTask(taskContent) {
emit('sendTask', taskContent);
}
</script>
<style scoped>
/* 这里可按需补充 */
</style>
<template>
<div class="p-4 border-t border-gray-300 dark:border-gray-700 bg-white dark:bg-[#222] flex gap-2">
<input
v-model="input"
placeholder="请输入任务内容"
class="border rounded px-3 py-2 flex-grow dark:bg-[#3a3a3f] dark:text-white"
:disabled="disabled"
@keydown.enter="send"
/>
<button @click="send" :disabled="disabled" class="bg-green-600 text-white px-4 rounded">
执行任务
</button>
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
const emit = defineEmits(['send']);
const props = defineProps({
disabled: Boolean,
});
const input = ref('');
function send() {
const val = input.value.trim();
if (val) {
emit('send', val);
input.value = '';
}
}
</script>
<template>
<div
:class="[
'max-w-[70%] p-3 rounded-lg whitespace-pre-wrap relative group',
message.think ? 'think-style' : '',
message.role === 'user'
? 'ml-auto bg-green-600 text-white'
: message.role === 'assistant'
? 'mr-auto bg-white dark:bg-[#222] text-black dark:text-white'
: message.role === 'tool'
? 'mr-auto bg-gray-200 dark:bg-gray-800 text-gray-800 dark:text-gray-100'
: 'mx-auto bg-gray-300 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
]"
>
<!-- 主内容复制按钮 -->
<button
v-if="message.content && message.role !== 'tool'"
@click="copyText(message.content)"
class="absolute top-2 right-2 text-xs text-gray-400 hover:text-blue-500 opacity-0 group-hover:opacity-100 transition"
title="复制内容"
>
📋
</button>
<!-- 用户消息 -->
<template v-if="message.role === 'user'">
<div class="flex items-center justify-end gap-2">
<span class="text-white">🙋‍♂️</span>
<b>你:</b>
</div>
<div class="markdown-body" v-html="renderMarkdown(message.content)"></div>
</template>
<!-- 助手消息 -->
<template v-else-if="message.role === 'assistant'">
<div class="flex items-center justify-start gap-2 mb-1 text-gray-400 italic select-text">
<span>🤖</span>
<b>助手:</b>
</div>
<div class="markdown-body" v-html="renderMarkdown(message.content)"></div>
<!-- 工具调用展示 -->
<div
v-if="message.tool_calls"
class="bg-[#222] text-[#eee] p-2 rounded mt-2 whitespace-pre-wrap font-normal text-xs relative group/tool cursor-pointer"
title="点击查看工具调用详情"
>
🛠️ 工具调用:
<div
v-for="toolCall in message.tool_calls"
:key="toolCall.id"
class="mt-1 border-t border-gray-600 pt-1 cursor-pointer"
@click.stop="$emit('selectToolCall', toolCall)"
title="点击查看工具调用详情"
>
<div>🔧 ID: <b>{{ toolCall.id }}</b></div>
<div>🔧 函数名: <b>{{ toolCall.function?.name }}</b></div>
<div>📥 参数: {{ toolCall.function?.arguments }}</div>
<!-- loading 判断 -->
<div v-if="pendingToolCallIds?.value?.has(toolCall.id)" class="text-yellow-400 animate-pulse mt-1">
⏳ 等待工具结果中...
</div>
</div>
<!-- 工具调用复制按钮 -->
<button
@click.stop="copyText(JSON.stringify(message.tool_calls, null, 2))"
class="absolute top-2 right-2 text-xs text-gray-400 hover:text-blue-500 opacity-0 group-hover/tool:opacity-100 transition"
title="复制工具调用 JSON"
>
📋
</button>
</div>
</template>
<!-- 工具返回 -->
<template v-else-if="message.role === 'tool'">
<div class="text-gray-500 text-xs mb-1">🛠️ 工具结果:</div>
<div class="markdown-body relative group/tool">
<div v-html="renderMarkdown(message.content)"></div>
<button
@click.stop="copyText(message.content)"
class="absolute top-2 right-2 text-xs text-gray-400 hover:text-blue-500 opacity-0 group-hover/tool:opacity-100 transition"
title="复制工具返回内容"
>
📋
</button>
</div>
</template>
<!-- 工具返回 -->
<template v-else-if="message.role === 'tool'">
<div class="text-gray-500 text-xs mb-1">🛠️ 工具结果:</div>
<div class="markdown-body relative group/tool">
<div v-html="renderMarkdown(message.content)"></div>
<button
@click.stop="copyText(message.content)"
class="absolute top-2 right-2 text-xs text-gray-400 hover:text-blue-500 opacity-0 group-hover/tool:opacity-100 transition"
title="复制工具返回内容"
>
📋
</button>
</div>
</template>
<!-- 其他系统信息 -->
<template v-else>
📢 <i>{{ message.content }}</i>
</template>
</div>
</template>
<script setup>
import MarkdownIt from 'markdown-it'
import useWebSocket from '@/pages/chat/composables/useWebSocket.js'
defineProps({
message: Object,
})
const emit = defineEmits(['selectToolCall'])
const { pendingToolCallIds } = useWebSocket()
// 初始化 markdown-it
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
})
// 渲染 markdown
function renderMarkdown(content) {
return md.render(content || '')
}
// 复制内容
function copyText(content) {
if (!content) return
const copyStr = typeof content === 'string' ? content : JSON.stringify(content, null, 2)
navigator.clipboard.writeText(copyStr).then(() => {
console.log('已复制')
}).catch((err) => {
console.error('复制失败', err)
})
}
</script>
<style scoped>
.think-style {
opacity: 0.6;
font-style: italic;
background-color: #282828 !important;
color: #888 !important;
user-select: none;
pointer-events: none;
}
.dark .think-style {
background-color: #3a3a3a !important;
color: #999 !important;
}
/* 主消息 hover 显示复制按钮 */
.group:hover .group-hover\:opacity-100 {
opacity: 1;
}
/* tool_calls 和 tool 区域单独 hover 控制 */
.group\/tool:hover .group-hover\/tool\:opacity-100 {
opacity: 1;
}
/* Markdown 样式 */
.markdown-body {
font-size: 0.95rem;
line-height: 1;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3 {
font-weight: bold;
margin-top: 1em;
}
.markdown-body code {
background: rgba(27, 31, 35, 0.05);
padding: 2px 4px;
border-radius: 4px;
font-family: monospace;
}
.markdown-body pre {
background: #f6f8fa;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
}
.dark .markdown-body pre {
background: #1e1e1e;
color: #eee;
}
.dark .markdown-body code {
background: #333;
color: #ddd;
}
</style>
<template>
<div
ref="messageBox"
class="flex-1 overflow-y-auto p-6 space-y-4 bg-gray-100 dark:bg-[#33343a] font-mono text-sm message-scroll-area"
>
<MessageItem
v-for="msg in messages"
:key="msg.stream_id || msg.id || msg.content"
:message="msg"
@selectToolCall="$emit('selectToolCall', $event)"
/>
</div>
</template>
<script setup>
import {ref, onUpdated, nextTick} from 'vue';
import MessageItem from './MessageItem.vue';
defineProps({
messages: Array,
});
const emit = defineEmits(['selectToolCall']);
const messageBox = ref(null);
onUpdated(() => {
nextTick(() => {
if (messageBox.value) {
messageBox.value.scrollTop = messageBox.value.scrollHeight;
}
});
});
</script>
<style scoped>
.message-scroll-area::-webkit-scrollbar {
width: 8px;
}
.message-scroll-area::-webkit-scrollbar-thumb {
background-color: rgba(100, 100, 100, 0.3);
border-radius: 4px;
transition: background-color 0.3s;
}
.message-scroll-area::-webkit-scrollbar-thumb:hover {
background-color: rgba(100, 100, 100, 0.6);
}
.message-scroll-area::-webkit-scrollbar-track {
background: transparent;
}
/* 深色主题下的滚动条 */
.dark .message-scroll-area::-webkit-scrollbar-thumb {
background-color: rgba(200, 200, 200, 0.2);
}
.dark .message-scroll-area::-webkit-scrollbar-thumb:hover {
background-color: rgba(200, 200, 200, 0.4);
}
</style>
<template>
<div class="flex flex-col h-full p-4">
<div class="flex items-center justify-between mb-2">
<div class="text-lg font-bold">会话列表</div>
<button @click="$emit('createSession')" class="bg-blue-600 text-white px-2 rounded text-sm">➕ 新建</button>
</div>
<div class="flex-1 overflow-y-auto space-y-2">
<div
v-for="id in sessionList"
:key="id"
@click="$emit('switchSession', id)"
class="p-2 rounded cursor-pointer text-sm break-all border border-gray-300 dark:border-gray-600"
:class="id === currentSessionId ? 'bg-blue-600 text-white' : 'bg-white dark:bg-[#3a3a3f] hover:bg-gray-100 dark:hover:bg-[#4a4a4f]'"
>
{{ id }}
</div>
</div>
</div>
</template>
<script setup>
defineProps({
sessionList: Array,
currentSessionId: String,
});
</script>
<template>
<div class="p-2 text-center text-sm select-none" :class="statusClass">
{{ statusText }}
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
status: String,
});
const statusText = computed(() => {
switch (props.status) {
case 'completed':
return '✅ 任务完成';
case 'idle':
return '🟢 空闲中,等待输入...';
case 'running':
default:
return '⏳ 等待任务或执行中...';
}
});
const statusClass = computed(() =>
props.status === 'completed' ? 'text-green-600' : 'text-blue-600'
);
</script>
<template>
<div ref="fullscreenRef" class="flex flex-col h-full bg-white dark:bg-[#1e1e20]">
<!-- 顶部操作栏 -->
<div
class="flex justify-between items-center p-4 border-b border-gray-300 dark:border-gray-700"
>
<h2 class="text-lg font-bold">
{{ toolCall?.function?.name || '工具调用详情' }}
</h2>
<div class="flex items-center gap-3">
<!-- 全屏按钮 -->
<!-- 全屏按钮 -->
<button
@click="toggleFullscreen"
class="w-8 h-8 flex items-center justify-center rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
:title="isFullscreen ? '退出全屏' : '全屏'"
aria-label="Toggle fullscreen"
type="button"
>
<svg
v-if="!isFullscreen"
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5 text-gray-700 dark:text-gray-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<!-- 进入全屏,四角向外 -->
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4h6M4 4v6M20 20h-6M20 20v-6M4 20l6-6M20 4l-6 6" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5 text-gray-700 dark:text-gray-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<!-- 退出全屏,四角向内 -->
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H4v6m0 0l6-6M20 4h-6v6m0 0l6-6" />
</svg>
</button>
<!-- 关闭按钮 -->
<button
@click="handleClose"
class="w-8 h-8 flex items-center justify-center rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
title="关闭"
aria-label="Close"
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5 text-red-600 dark:text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- 渲染子组件 -->
<component
:is="currentComponent"
:toolCall="toolCall"
:toolCallResult="toolCallResult"
class="flex-1 overflow-auto"
/>
</div>
</template>
<script setup>
import {ref, computed, onMounted, onBeforeUnmount} from 'vue';
import DefaultToolCallDetail from './tool_call/DefaultToolCallDetail.vue';
import DynamicChart from './tool_call/DynamicChart.vue';
import ShowVue3CodeDetail from './tool_call/ShowVue3CodeDetail.vue';
import GoogleSearchResult from "@/pages/chat/components/tool_call/GoogleSearchResult.vue";
const props = defineProps({
toolCall: Object,
messages: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['close']);
function handleClose() {
emit('close');
}
// 筛选工具调用结果
const toolCallResult = computed(() => {
if (!props.toolCall?.id || !Array.isArray(props.messages)) return null;
// console.log(props.messages)
return props.messages.find(m =>
m.role === 'tool' &&
m.content?.tool_call_id &&
String(m.content.tool_call_id) === String(props.toolCall.id)
)?.content ?? null;
});
// 渲染子组件类型
const currentComponent = computed(() => {
const name = props.toolCall.function?.name
if (name === 'show_rendered_element') {
return ShowVue3CodeDetail
} else if (name === 'show_rendered_chart') {
return DynamicChart
} else if (name === 'search_and_extract') {
return GoogleSearchResult
} else {
return DefaultToolCallDetail
}
})
// 全屏控制
const fullscreenRef = ref(null);
const isFullscreen = ref(false);
function toggleFullscreen() {
const el = fullscreenRef.value;
if (!el) return;
if (!isFullscreen.value) {
el.requestFullscreen?.();
} else {
document.exitFullscreen?.();
}
}
function fullscreenChangeListener() {
const currentFullscreenElement = document.fullscreenElement;
isFullscreen.value = fullscreenRef.value === currentFullscreenElement;
}
onMounted(() => {
document.addEventListener('fullscreenchange', fullscreenChangeListener);
});
onBeforeUnmount(() => {
document.removeEventListener('fullscreenchange', fullscreenChangeListener);
});
</script>
<template>
<div class="p-4 text-xs space-y-4 bg-gray-200 dark:bg-gray-800 rounded h-full overflow-auto">
<div>
<h3 class="font-semibold mb-1">调用ID</h3>
<div>{{ toolCall.id }}</div>
</div>
<div>
<h3 class="font-semibold mb-1">函数名称</h3>
<div>{{ toolCall.function?.name || '' }}</div>
</div>
<div>
<h3 class="font-semibold mb-1">调用参数</h3>
<pre class="whitespace-pre-wrap bg-gray-100 dark:bg-gray-700 p-2 rounded">
{{ JSON.stringify(toolCall.function?.arguments || {}, null, 2) }}
</pre>
</div>
<div>
<h3 class="font-semibold mb-1">调用状态</h3>
<div>{{ statusText }}</div>
</div>
<div v-if="toolCallResult">
<h3 class="font-semibold mb-1">调用结果</h3>
<pre class="whitespace-pre-wrap bg-gray-100 dark:bg-gray-700 p-2 rounded">
{{ callResult }}
</pre>
</div>
<div v-else>
<i>无调用结果,可能还在等待中...</i>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import useWebSocket from "@/pages/chat/composables/useWebSocket.js";
// 获取 WebSocket 消息列表
const { messages } = useWebSocket();
// 接收 props
const props = defineProps({
toolCall: Object,
toolCallResult: [String, Object, Array, null]
});
const callResult = computed(()=>{
try{
return JSON.parse(props.toolCallResult.content)
}catch (e){
return props.toolCallResult.content
}
})
// 状态文本
const statusText = computed(() => {
return props.toolCallResult.content ? '已完成' : '等待中';
});
</script>
<template>
<div class="p-4">
<div v-if="parsedOption" ref="chartRef" class="w-full h-[400px]" />
<pre v-else class="whitespace-pre-wrap bg-gray-100 p-2 dark:bg-[#1e1e20] rounded">
{{ toolCallResult?.content || '等待数据中...' }}
</pre>
</div>
</template>
<script setup>
import {ref, watch, onMounted, onBeforeUnmount, nextTick, computed} from 'vue'
import * as echarts from 'echarts'
const props = defineProps({
toolCall: Object,
toolCallResult: [String, Object, Array, null]
})
const chartRef = ref(null)
let chartInstance = null
let resizeObserver = null
function unwrapOption(option) {
while (option && option.option) {
option = option.option
}
return option
}
const parsedOption = computed(() => {
if (!props.toolCallResult || !props.toolCallResult.content) {
// 数据没到时返回null,等待数据
return null
}
try {
const parsed = JSON.parse(props.toolCallResult.content)
if (parsed.success === true && parsed.option) {
const opt = typeof parsed.option === 'string' ? JSON.parse(parsed.option) : parsed.option
return unwrapOption(opt)
}
return null
} catch (e) {
console.warn('解析失败:', e)
return null
}
})
function initChart() {
const el = chartRef.value
if (!el || !parsedOption.value) return
const {clientWidth, clientHeight} = el
if (clientWidth === 0 || clientHeight === 0) {
// 等待容器渲染出尺寸后再初始化
setTimeout(initChart, 100)
return
}
if (!chartInstance) {
chartInstance = echarts.init(el)
resizeObserver = new ResizeObserver(() => {
chartInstance?.resize()
})
resizeObserver.observe(el)
}
chartInstance.setOption(parsedOption.value, true)
}
function disposeChart() {
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
}
// 监听 parsedOption,数据变化时初始化或更新图表
watch(parsedOption, (newVal) => {
if (newVal) {
nextTick(() => {
initChart()
})
} else {
disposeChart()
}
}, {immediate: true})
onBeforeUnmount(() => {
disposeChart()
})
</script>
<template>
<div class="p-4 text-xs bg-gray-200 dark:bg-gray-800 rounded h-full overflow-auto">
<!-- 若未进入预览状态,显示结果列表 -->
<template v-if="!previewUrl">
<div v-if="results.length" class="space-y-4">
<div v-for="(item, index) in results" :key="index" class="p-3 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 space-y-2">
<!-- URL -->
<div>
<a :href="item.url" class="text-blue-600 hover:underline break-all" target="_blank">
🔗 {{ item.url }}
</a>
</div>
<!-- 摘要 + 按钮 -->
<div class="text-gray-800 dark:text-gray-100 whitespace-pre-wrap">
{{ truncate(item.text, 100) }}
<button class="text-blue-500 ml-2 underline" @click="previewUrl = item.url">
阅读全文
</button>
</div>
</div>
</div>
<div v-else>
<i>无调用结果,可能还在等待中...</i>
</div>
</template>
<!-- 网页预览模式:iframe 替代整个组件内容 -->
<template v-else>
<div class="flex justify-between items-center bg-gray-300 dark:bg-gray-700 p-2 text-xs mb-2 rounded">
<span class="truncate">网页预览:{{ previewUrl }}</span>
<button class="text-red-500 font-bold" @click="previewUrl = null">✕ 返回结果列表</button>
</div>
<iframe
class="w-full h-[600px] border-0 rounded"
:src="previewUrl"
loading="lazy"
/>
</template>
</div>
</template>
<script setup>
import {computed, ref} from 'vue'
const props = defineProps({
toolCallResult: [String, Object, Array, null]
})
// 提取搜索结果
const results = computed(() => {
const content = props.toolCallResult?.content
try {
const parsed = typeof content === 'string' ? JSON.parse(content) : content
return Array.isArray(parsed) ? parsed : []
} catch (e) {
return []
}
})
// 当前预览的 URL
const previewUrl = ref(null)
// 截断文本
const truncate = (text, length = 100) => {
return text?.length > length ? text.slice(0, length) + '...' : text
}
</script>
<template>
<div class="p-4">
<component v-if="dynamicComponent" :is="dynamicComponent" />
<pre v-else class="whitespace-pre-wrap bg-gray-100 p-2 rounded">
{{ rawCode }}
</pre>
</div>
</template>
<script setup>
import { ref, defineComponent, watch, computed } from 'vue'
import * as Vue from 'vue'
import { compile } from '@vue/compiler-dom'
const props = defineProps({
toolCall: Object,
toolCallResult: [String, Object, Array, null]
})
const dynamicComponent = ref(null)
// 取出 arguments.code 的原始值(string)
const rawCode = computed(() => {
try {
const args = props.toolCall?.function?.arguments
const parsed = typeof args === 'string' ? JSON.parse(args) : args
return parsed?.code || ''
} catch (e) {
console.warn('无法解析 toolCall.function.arguments', e)
return ''
}
})
// 清洗模型返回的 HTML 字符串
function cleanCode(code) {
if (typeof code !== 'string') return ''
try {
return JSON.parse(code)
} catch (e) {
return code.trim().replace(/^"(.*)"$/, '$1').replace(/^`(.*)`$/, '$1')
}
}
// 动态创建 Vue 组件
function createDynamicComponent(template) {
const code = compile(template, {mode: 'function'}).code
const render = new Function('Vue', code)(Vue)
return defineComponent({
data() {
return {count: 0}
},
render
})
}
// 自动监听 rawCode 并渲染组件
watch(
rawCode,
(raw) => {
if (raw) {
try {
const cleaned = cleanCode(raw)
console.log('[最终模板]', cleaned)
dynamicComponent.value = createDynamicComponent(cleaned)
} catch (e) {
console.error('编译失败:', e)
dynamicComponent.value = null
}
}
},
{immediate: true}
)
</script>
export const sourceCode = `
<template><div>简单测试</div></template>
<script>export default {}</script>
<style></style>
`
\ No newline at end of file
import { ref } from 'vue';
const sessionList = ref([]);
const sessionId = ref('');
function addSession(id) {
if (!sessionList.value.includes(id)) {
sessionList.value.unshift(id);
}
sessionId.value = id;
}
function setSessionId(id) {
sessionId.value = id;
}
export default function useSessionStore() {
return {
sessionList,
sessionId,
addSession,
setSessionId,
};
}
import { ref } from 'vue';
const selectedToolCall = ref(null);
function setSelectedToolCall(toolCall) {
selectedToolCall.value = toolCall;
}
function clearSelectedToolCall() {
selectedToolCall.value = null;
}
export default function useToolCallStore() {
return {
selectedToolCall,
setSelectedToolCall,
clearSelectedToolCall,
};
}
import { ref, nextTick } from 'vue';
import useToolCallStore from "@/pages/chat/composables/useToolCallStore.js";
import {reactive} from "@vue/runtime-dom";
const messages = ref([]);
const status = ref('idle');
let ws = null;
// 用来记录流消息,key是stream_id,value是消息对象
const streamMap = new Map();
const pendingToolCallIds = ref(new Set());
const { setSelectedToolCall } = useToolCallStore();
function connectSession(sessionId) {
if (ws) {
ws.close();
}
messages.value = [];
status.value = 'idle';
streamMap.clear();
ws = new WebSocket(`ws://127.0.0.1:4010/api/chat/ws/${sessionId}`);
function routeToolCalls(calls) {
if (!Array.isArray(calls)) return;
calls.forEach(call => {
const fn = call.function;
const fnName = typeof fn === 'string' ? fn : fn?.name;
switch (fnName) {
case 'show_rendered_chart':
handleShowRendered(call);
break;
// ✅ 你可以在此添加更多 case:
case 'show_rendered_element':
handleShowRendered(call);
break;
default:
console.warn('未处理的工具调用:', fnName, call);
}
});
}
function handleShowRendered(call) {
console.log('处理图表渲染调用:', call);
setSelectedToolCall(call)
// 可选:你可以在这里发事件、更新状态、调接口等
// 比如:emit('renderChart', call.function.arguments)
}
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// console.log('收到流式消息:', data);
/*
假设data结构示例:
{
stream_id: 'xxx',
type: 'content' | 'done' | 'tool_calls' | 'tool_calls_result',
text: '文本内容',
info: 工具调用信息,
role: 'assistant' | 'tool' | ...
think: true|false,
...
}
*/
const { stream_id, type, text, info, role, think } = data;
if (!stream_id) {
// 非流式消息,或格式异常,忽略或另处理
return;
}
let msg = streamMap.get(stream_id);
if (!msg) {
// 新消息,初始化
msg = {
stream_id,
role: role || (type === 'tool_calls_result' ? 'tool' : 'assistant'),
content: '',
think: !!think,
};
streamMap.set(stream_id, msg);
messages.value.push(msg);
}
if (type === 'content') {
msg.content += text || '';
}
// === 工具调用流式过程(实时 tool_calls_acc 列表) ===
else if (type === 'tool_call_stream') {
msg.tool_calls = info || [];
}
else if (type === 'tool_calls') {
msg.tool_calls = info || null;
if (Array.isArray(info)) {
// 收集所有 call.id
info.forEach(call => {
if (call.id) {
pendingToolCallIds.value.add(call.id);
}
});
// 调用路由函数进行处理
routeToolCalls(info);
}
} else if (type === 'tool_calls_result') {
// 工具调用结果直接覆盖内容
msg.content = info || {};
pendingToolCallIds.value.delete(info.tool_call_id);
} else if (type === 'done') {
// 流结束,更新状态
status.value = 'completed';
}
// 触发响应式更新,刷新界面
// 先找到 messages 中对应的索引
const idx = messages.value.findIndex(m => m.stream_id === msg.stream_id);
if (idx !== -1) {
messages.value.splice(idx, 1, { ...msg });
}
// 保证滚动条滚动到底部(如果需要)
nextTick(() => {
const el = document.querySelector('.message-box');
if (el) {
el.scrollTop = el.scrollHeight;
}
});
};
ws.onclose = () => {
status.value = 'idle';
};
ws.onerror = (e) => {
console.error('[WS] 错误:', e);
};
}
function sendTask(content) {
if (ws && content.trim()) {
messages.value.push({ role: 'user', content });
ws.send(JSON.stringify({ action: 'start', content }));
status.value = 'running';
}
}
function closeWebSocket() {
if (ws) {
ws.close();
}
}
export default function useWebSocket() {
return {
messages,
status,
connectSession,
sendTask,
closeWebSocket,
pendingToolCallIds
};
}
<template>
<div
class="chat-page flex h-screen bg-white dark:bg-[#1e1e20] text-black dark:text-white select-none"
>
<!-- 左侧会话列表 -->
<aside class="w-80 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-[#2c2c32]">
<SessionList
:sessionList="sessionList"
:currentSessionId="sessionId"
@createSession="createSession"
@switchSession="connectSession"
/>
</aside>
<!-- 中间区域:聊天区和工具栏包裹 -->
<div class="middle-wrapper flex-1 flex justify-center relative overflow-hidden">
<!-- 聊天区,最大宽度,初始居中 -->
<main
class="chat-area flex flex-col max-w-4xl w-full p-4 bg-gray-100 dark:bg-[#33343a] font-mono text-sm rounded-lg transition-margin duration-300"
:class="{ 'with-tool-sidebar': toolCallSidebarVisible }"
>
<ChatArea
:messages="messages"
:status="status"
@sendTask="startTask"
@selectToolCall="selectToolCall"
/>
</main>
<!-- 右侧工具栏 -->
<aside
class="tool-sidebar border-l border-gray-200 dark:border-gray-700 bg-gray-100 dark:bg-[#2c2c32] absolute right-0 top-0 h-full overflow-y-auto transition-width duration-300 ease-in-out"
:style="{ width: toolCallSidebarVisible ? '24rem' : '0', padding: toolCallSidebarVisible ? '1rem' : '0' }"
>
<ToolCallSidebar
v-if="toolCallSidebarVisible"
:toolCall="selectedToolCall"
:messages="messages"
@close="closeToolCallSidebar"
/>
</aside>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import SessionList from './components/SessionList.vue';
import ChatArea from './components/ChatArea.vue';
import ToolCallSidebar from './components/ToolCallSidebar.vue';
import useWebSocket from './composables/useWebSocket.js';
import useSessionStore from './composables/useSessionStore.js';
import useToolCallStore from './composables/useToolCallStore.js';
// 会话管理相关
const { sessionList, sessionId, setSessionId, addSession } = useSessionStore();
// WebSocket 通信相关
const { messages, status, connectSession, sendTask, closeWebSocket } = useWebSocket();
// 工具调用侧栏状态
const { selectedToolCall, setSelectedToolCall, clearSelectedToolCall } = useToolCallStore();
function createSession() {
const newId = crypto.randomUUID();
addSession(newId);
connectSession(newId);
}
function startTask(taskContent) {
sendTask(taskContent);
}
function selectToolCall(toolCall) {
setSelectedToolCall(toolCall);
}
function closeToolCallSidebar() {
clearSelectedToolCall();
}
const toolCallSidebarVisible = computed(() => !!selectedToolCall.value);
</script>
<style scoped>
.chat-page {
height: 100vh;
width: 100vw;
}
/* 中间区域,左右flex居中 */
.middle-wrapper {
position: relative;
}
/* 聊天区,宽度自适应,最大宽度4xl(约1024px),平时margin自动居中 */
.chat-area {
margin-left: auto;
margin-right: auto;
height: 100vh;
box-sizing: border-box;
overflow-y: auto;
background-color: inherit;
transition: transform 0.3s ease;
}
/* 工具栏展开时,向左平移 */
.chat-area.with-tool-sidebar {
transform: translateX(-3rem); /* 左移一半工具栏宽度,看起来正好让位 */
}
/* 展开工具栏时,聊天区右边加 margin,腾出工具栏位置 */
.chat-area.with-tool-sidebar {
margin-right: 24rem; /* 给右侧工具栏留宽度 */
transition: margin-right 0.3s ease;
}
/* 工具栏宽度和动画 */
.tool-sidebar {
transition: width 0.3s ease, padding 0.3s ease;
box-sizing: border-box;
pointer-events: auto;
z-index: 10;
}
/* 宽度为0时禁用交互 */
.tool-sidebar[style*='width: 0'] {
pointer-events: none;
overflow: hidden;
}
</style>
\ No newline at end of file
.chat-page {
height: 100vh;
display: flex;
background-color: var(--bg-white);
}
.chat-main-shrink {
width: calc(100% - 24rem);
}
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
// import ChatPage from '@/pages/chat/chat.vue'
import ChatPage from '@/pages/chat/index.vue'
const routes = [
{
path: '/chat/:id',
name: 'ChatPage',
component: ChatPage,
props: true, // 👈 自动将 id 作为 prop 传递给组件
},
{
path: '/',
redirect: '/chat/default', // 默认跳转(你可以改成你想要的)
}
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router
// tailwind.config.js
module.exports = {
darkMode: 'media', // 👈 跟随系统
// 或者 'class' 模式,手动加类也可以
content: ['./index.html', './src/**/*.{vue,js,ts}'],
theme: {
extend: {},
},
plugins: [],
}
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
optimizeDeps: {
exclude: ['vue3-sfc-loader']
},
server: {
host: '0.0.0.0',
port: 4010,
proxy: {
'/api': {
target: 'http://0.0.0.0:8000',
changeOrigin: true,
ws: true, // ✅ 加这一行!!
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment