- 新增 docs/ai/README.md 包含完整的AI模块配置、建表、API文档 - 重构 src/views/ai/index.vue 组件,移除硬编码BASE_URL和多余参数 - 添加 src/api/ai/backend.ts 统一的AI后端API接口实现 - 集成模型列表、流式对话、非流式对话等功能 - 实现SE流式响应处理和鉴权头自动携带 - 移除历史消息存储和温度参数等冗余功能
197 lines
5.3 KiB
Vue
197 lines
5.3 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, ref } from 'vue';
|
||
import { message } from 'ant-design-vue';
|
||
import {
|
||
aiChat,
|
||
aiChatStream,
|
||
aiListModels,
|
||
normalizeModels
|
||
} from '@/api/ai/backend';
|
||
|
||
const modelLoading = ref(false);
|
||
const models = ref<Array<{ id: string; name?: string }>>([]);
|
||
const modelId = ref<string>('');
|
||
|
||
const userPrompt = ref<string>('你好,介绍一下你能做什么。');
|
||
|
||
const stream = ref<boolean>(true);
|
||
|
||
const sending = ref(false);
|
||
const assistantText = ref<string>('');
|
||
const errorText = ref<string>('');
|
||
|
||
const canSend = computed(() => {
|
||
return !!userPrompt.value.trim() && !sending.value;
|
||
});
|
||
|
||
const abortController = ref<AbortController | null>(null);
|
||
|
||
const stop = () => {
|
||
abortController.value?.abort();
|
||
abortController.value = null;
|
||
sending.value = false;
|
||
};
|
||
|
||
const loadModels = async () => {
|
||
modelLoading.value = true;
|
||
errorText.value = '';
|
||
try {
|
||
const res = await aiListModels();
|
||
const ms = normalizeModels(res);
|
||
models.value = ms
|
||
.map((m) => ({
|
||
id: String(m.name ?? m.id ?? ''),
|
||
name: String(m.name ?? m.id ?? '')
|
||
}))
|
||
.filter((m) => !!m.id);
|
||
if (!modelId.value && models.value.length) {
|
||
modelId.value = models.value[0].id;
|
||
}
|
||
} catch (e: any) {
|
||
errorText.value = e?.message ?? String(e);
|
||
message.error('加载模型列表失败');
|
||
} finally {
|
||
modelLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const clearChat = () => {
|
||
assistantText.value = '';
|
||
errorText.value = '';
|
||
};
|
||
|
||
const send = async () => {
|
||
if (!canSend.value) return;
|
||
|
||
sending.value = true;
|
||
assistantText.value = '';
|
||
errorText.value = '';
|
||
|
||
const prompt = userPrompt.value.trim();
|
||
|
||
const controller = new AbortController();
|
||
abortController.value = controller;
|
||
|
||
try {
|
||
if (stream.value) {
|
||
await aiChatStream(
|
||
{ prompt },
|
||
{
|
||
signal: controller.signal,
|
||
onDelta: (t) => {
|
||
assistantText.value += t;
|
||
}
|
||
}
|
||
);
|
||
} else {
|
||
// axios 已在拦截器里自动带上 token/tenant
|
||
assistantText.value = await aiChat({ prompt });
|
||
}
|
||
userPrompt.value = '';
|
||
} catch (e: any) {
|
||
// Abort is expected when clicking "Stop".
|
||
if (e?.name !== 'AbortError') {
|
||
errorText.value = e?.message ?? String(e);
|
||
message.error('请求失败(可能是后端未启动 / 鉴权 / 租户头缺失)');
|
||
}
|
||
} finally {
|
||
sending.value = false;
|
||
abortController.value = null;
|
||
}
|
||
};
|
||
|
||
const modelOptions = computed(() => {
|
||
return models.value.map((m) => ({
|
||
label: m.name ?? m.id,
|
||
value: m.id
|
||
}));
|
||
});
|
||
|
||
// Load once for convenience; if the gateway blocks CORS you can still paste output from curl.
|
||
loadModels();
|
||
|
||
onBeforeUnmount(() => {
|
||
stop();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="ele-body ele-body-card">
|
||
<a-card :bordered="false" :body-style="{ padding: '16px' }">
|
||
<a-space direction="vertical" style="width: 100%" :size="12">
|
||
<a-alert
|
||
type="info"
|
||
show-icon
|
||
message="AI 助手"
|
||
description="支持Qwen3.5、DeepSeek、Gemini3等主流的开源大模型,免费使用"
|
||
/>
|
||
|
||
<a-row :gutter="12">
|
||
<a-col :xs="24" :md="12">
|
||
<a-select
|
||
v-model:value="modelId"
|
||
:options="modelOptions"
|
||
:loading="modelLoading"
|
||
placeholder="选择模型"
|
||
style="width: 100%"
|
||
/>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-space>
|
||
<a-button :loading="modelLoading" @click="loadModels"
|
||
>刷新模型</a-button
|
||
>
|
||
<span>流式</span>
|
||
<a-switch v-model:checked="stream" />
|
||
</a-space>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<!-- <a-textarea-->
|
||
<!-- v-model:value="systemPrompt"-->
|
||
<!-- :auto-size="{ minRows: 2, maxRows: 6 }"-->
|
||
<!-- placeholder="System Prompt(可选)"-->
|
||
<!-- />-->
|
||
|
||
<a-textarea
|
||
v-model:value="userPrompt"
|
||
:auto-size="{ minRows: 3, maxRows: 10 }"
|
||
placeholder="输入要问的问题..."
|
||
@pressEnter.exact.prevent="send"
|
||
/>
|
||
|
||
<a-space>
|
||
<a-button
|
||
type="primary"
|
||
:disabled="!canSend"
|
||
:loading="sending"
|
||
@click="send"
|
||
>发送</a-button
|
||
>
|
||
<a-button v-if="sending" danger @click="stop">停止</a-button>
|
||
<a-button @click="clearChat">清空</a-button>
|
||
</a-space>
|
||
<a-alert v-if="errorText" type="error" show-icon :message="errorText" />
|
||
|
||
<a-divider>输出</a-divider>
|
||
<a-card size="small" :bordered="true">
|
||
<pre class="output">{{ assistantText }}</pre>
|
||
</a-card>
|
||
|
||
<!-- <a-divider>历史(最后一次请求)</a-divider>-->
|
||
<!-- <a-card size="small" :bordered="true">-->
|
||
<!-- <pre class="output">{{ historyJson }}</pre>-->
|
||
<!-- </a-card>-->
|
||
</a-space>
|
||
</a-card>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped lang="less">
|
||
.output {
|
||
margin: 0;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
</style>
|