forked from b2894lxlx/tuanwei-admin
feat(ai): 写作模式拆成自由创作与模板两条路(issue 18)
自由创作不再解析自然语言:原话整段作为 userContent 下发,docType 留空,由后端转 「AI 创意助手」。选了文体才提交 docType 等结构化条件,走「内容创作与宣传助手」。 - writing-params.ts 删掉 parseWritingRequest、字段别名表与字数兜底表,只留选项常量。 - chatgpt/index.vue:字数选择器只在选中文体后出现;撤掉图片比例选择器(上游开始 节点无比例变量,只能拼进 user_content,等于替上游写 prompt);结果不再用入参补标题。 - gxmu/manuscriptGen:新增「自由创作」入口与 freeContent 文本域,模板模式才展开活动 信息等可选项。 - 顺带删除 chatgpt/index.bak.vue 旧备份。
This commit is contained in:
@@ -1,7 +1,10 @@
|
|||||||
import type { FileRecord } from '@/api/system/file/model';
|
import type { FileRecord } from '@/api/system/file/model';
|
||||||
|
|
||||||
export interface ManuscriptGenParams {
|
export interface ManuscriptGenParams {
|
||||||
|
/** 文稿模板;留空表示自由创作,后端会转「AI 创意助手」 */
|
||||||
docType?: string;
|
docType?: string;
|
||||||
|
/** 自由创作时用户想写的内容,转创意助手后作为 user_content 下发 */
|
||||||
|
userContent?: string;
|
||||||
eventType?: string;
|
eventType?: string;
|
||||||
theme?: string;
|
theme?: string;
|
||||||
time?: string;
|
time?: string;
|
||||||
|
|||||||
@@ -22,18 +22,12 @@
|
|||||||
<a-button block @click="fillDemo('news')">活动新闻稿</a-button>
|
<a-button block @click="fillDemo('news')">活动新闻稿</a-button>
|
||||||
<a-button block @click="fillDemo('wechat')">公众号推文</a-button>
|
<a-button block @click="fillDemo('wechat')">公众号推文</a-button>
|
||||||
<a-button block @click="fillDemo('speech')">发言稿提纲</a-button>
|
<a-button block @click="fillDemo('speech')">发言稿提纲</a-button>
|
||||||
|
<a-button block @click="fillDemo('free')">自由创作示例</a-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a-card :bordered="false" class="panel-card">
|
<a-card :bordered="false" class="panel-card">
|
||||||
<template #title>创作配置</template>
|
<template #title>创作配置</template>
|
||||||
<a-form layout="vertical">
|
<a-form layout="vertical">
|
||||||
<a-form-item label="稿件标题">
|
|
||||||
<a-input
|
|
||||||
v-model:value="form.theme"
|
|
||||||
allow-clear
|
|
||||||
placeholder="例如:广西医科大学开展2026年春季志愿服务活动"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="稿件类型">
|
<a-form-item label="稿件类型">
|
||||||
<a-select
|
<a-select
|
||||||
v-model:value="form.docType"
|
v-model:value="form.docType"
|
||||||
@@ -41,60 +35,78 @@
|
|||||||
placeholder="请选择稿件类型"
|
placeholder="请选择稿件类型"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="活动类型">
|
<a-form-item v-if="!templateMode" label="想创作的内容">
|
||||||
<a-select
|
|
||||||
v-model:value="form.eventType"
|
|
||||||
:options="eventTypeOptions"
|
|
||||||
placeholder="请选择活动类型"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="活动时间">
|
|
||||||
<a-input
|
|
||||||
v-model:value="form.time"
|
|
||||||
allow-clear
|
|
||||||
placeholder="例如:2026年4月8日下午"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="活动地点">
|
|
||||||
<a-input
|
|
||||||
v-model:value="form.location"
|
|
||||||
allow-clear
|
|
||||||
placeholder="例如:广西医科大学校史馆广场"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="参与人员">
|
|
||||||
<a-input
|
|
||||||
v-model:value="form.participants"
|
|
||||||
allow-clear
|
|
||||||
placeholder="例如:校团委老师、青年志愿者代表共120人"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="亮点内容">
|
|
||||||
<a-textarea
|
<a-textarea
|
||||||
v-model:value="form.highlights"
|
v-model:value="form.freeContent"
|
||||||
:auto-size="{ minRows: 5, maxRows: 8 }"
|
:auto-size="{ minRows: 6, maxRows: 12 }"
|
||||||
placeholder="填写活动亮点、流程、成果、典型细节等关键信息"
|
placeholder="例如:帮我写一段迎新晚会的主持人开场词,口语化一点,时长两分钟左右"
|
||||||
/>
|
/>
|
||||||
|
<div class="upload-tip">
|
||||||
|
不套模板时,这段内容会作为 `user_content` 交给「AI 创意助手」;选上面的文稿模板后可填写活动信息等可选参数。
|
||||||
|
</div>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="建议篇幅">
|
<template v-if="templateMode">
|
||||||
<a-input-number
|
<a-form-item label="稿件标题">
|
||||||
v-model:value="form.number"
|
<a-input
|
||||||
class="full-width"
|
v-model:value="form.theme"
|
||||||
:min="100"
|
allow-clear
|
||||||
:max="5000"
|
placeholder="例如:广西医科大学开展2026年春季志愿服务活动"
|
||||||
:step="100"
|
/>
|
||||||
placeholder="请输入建议字数"
|
</a-form-item>
|
||||||
/>
|
<a-form-item label="活动类型">
|
||||||
</a-form-item>
|
<a-select
|
||||||
<a-form-item label="附件材料">
|
v-model:value="form.eventType"
|
||||||
<a-upload
|
:options="eventTypeOptions"
|
||||||
:multiple="true"
|
placeholder="请选择活动类型"
|
||||||
:show-upload-list="false"
|
/>
|
||||||
:customRequest="handleUpload"
|
</a-form-item>
|
||||||
>
|
<a-form-item label="活动时间">
|
||||||
<a-button>
|
<a-input
|
||||||
<template #icon>
|
v-model:value="form.time"
|
||||||
<UploadOutlined />
|
allow-clear
|
||||||
|
placeholder="例如:2026年4月8日下午"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="活动地点">
|
||||||
|
<a-input
|
||||||
|
v-model:value="form.location"
|
||||||
|
allow-clear
|
||||||
|
placeholder="例如:广西医科大学校史馆广场"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="参与人员">
|
||||||
|
<a-input
|
||||||
|
v-model:value="form.participants"
|
||||||
|
allow-clear
|
||||||
|
placeholder="例如:校团委老师、青年志愿者代表共120人"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="亮点内容">
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="form.highlights"
|
||||||
|
:auto-size="{ minRows: 5, maxRows: 8 }"
|
||||||
|
placeholder="填写活动亮点、流程、成果、典型细节等关键信息"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="建议篇幅">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="form.number"
|
||||||
|
class="full-width"
|
||||||
|
:min="100"
|
||||||
|
:max="5000"
|
||||||
|
:step="100"
|
||||||
|
placeholder="请输入建议字数"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="附件材料">
|
||||||
|
<a-upload
|
||||||
|
:multiple="true"
|
||||||
|
:show-upload-list="false"
|
||||||
|
:customRequest="handleUpload"
|
||||||
|
>
|
||||||
|
<a-button>
|
||||||
|
<template #icon>
|
||||||
|
<UploadOutlined />
|
||||||
</template>
|
</template>
|
||||||
上传附件
|
上传附件
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -119,6 +131,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
</template>
|
||||||
</a-form>
|
</a-form>
|
||||||
|
|
||||||
<div class="submit-bar">
|
<div class="submit-bar">
|
||||||
@@ -136,8 +149,9 @@
|
|||||||
<div class="hint-block">
|
<div class="hint-block">
|
||||||
<div class="hint-title">提交说明</div>
|
<div class="hint-title">提交说明</div>
|
||||||
<div class="hint-text">
|
<div class="hint-text">
|
||||||
页面会先提交 `POST /ai/manuscriptGen`,再按 `/ai` 页面的方式通过 WebSocket
|
未选模板(自由创作)时只提交一段内容,后端转「AI 创意助手」;选了模板才提交
|
||||||
流式接收生成结果;如果后端仍然直接返回正文,页面也会自动兼容解析。
|
`docType` 等结构化条件。两种都会先 `POST /ai/manuscriptGen`,再按 `/ai` 页面的方式
|
||||||
|
通过 WebSocket 流式接收生成结果;如果后端仍然直接返回正文,页面也会自动兼容解析。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a-card>
|
</a-card>
|
||||||
@@ -227,7 +241,9 @@
|
|||||||
import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result';
|
import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result';
|
||||||
|
|
||||||
interface FormState {
|
interface FormState {
|
||||||
|
/** 空串 = 自由创作(不套模板);非空 = 文稿模板,走「内容创作与宣传助手」 */
|
||||||
docType: string;
|
docType: string;
|
||||||
|
freeContent: string;
|
||||||
eventType: string;
|
eventType: string;
|
||||||
theme: string;
|
theme: string;
|
||||||
time: string;
|
time: string;
|
||||||
@@ -243,7 +259,7 @@
|
|||||||
summary: string;
|
summary: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DemoType = 'news' | 'wechat' | 'speech';
|
type DemoType = 'news' | 'wechat' | 'speech' | 'free';
|
||||||
type StreamResolver = {
|
type StreamResolver = {
|
||||||
resolve: () => void;
|
resolve: () => void;
|
||||||
reject: (error: Error) => void;
|
reject: (error: Error) => void;
|
||||||
@@ -275,8 +291,13 @@
|
|||||||
const THINK_OPEN_TAG = '<think>';
|
const THINK_OPEN_TAG = '<think>';
|
||||||
const THINK_CLOSE_TAG = '</think>';
|
const THINK_CLOSE_TAG = '</think>';
|
||||||
|
|
||||||
|
/** 「自由创作」的取值:选中它时不下发 docType,后端转「AI 创意助手」 */
|
||||||
|
const DOC_TYPE_FREE = '自由创作';
|
||||||
|
|
||||||
const form = reactive<FormState>({
|
const form = reactive<FormState>({
|
||||||
docType: '新闻稿',
|
// 默认自由创作:未选模板就说清想写什么,交给「AI 创意助手」
|
||||||
|
docType: DOC_TYPE_FREE,
|
||||||
|
freeContent: '',
|
||||||
eventType: '校园活动',
|
eventType: '校园活动',
|
||||||
theme: '',
|
theme: '',
|
||||||
time: '',
|
time: '',
|
||||||
@@ -287,6 +308,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const docTypeOptions = [
|
const docTypeOptions = [
|
||||||
|
// 自由创作:表单只留一个内容输入框,提交时不带 docType
|
||||||
|
{ label: '自由创作(不套模板)', value: DOC_TYPE_FREE },
|
||||||
{ label: '新闻稿', value: '新闻稿' },
|
{ label: '新闻稿', value: '新闻稿' },
|
||||||
{ label: '公众号推文', value: '公众号推文' },
|
{ label: '公众号推文', value: '公众号推文' },
|
||||||
{ label: '宣传稿', value: '宣传稿' },
|
{ label: '宣传稿', value: '宣传稿' },
|
||||||
@@ -304,7 +327,13 @@
|
|||||||
{ label: '品牌项目', value: '品牌项目' }
|
{ label: '品牌项目', value: '品牌项目' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 选了文稿模板才展开其余可选参数 */
|
||||||
|
const templateMode = computed(() => !!form.docType && form.docType !== DOC_TYPE_FREE);
|
||||||
|
|
||||||
const canSubmit = computed(() => {
|
const canSubmit = computed(() => {
|
||||||
|
if (!templateMode.value) {
|
||||||
|
return !!form.freeContent.trim();
|
||||||
|
}
|
||||||
return !!(form.theme.trim() || form.highlights.trim() || uploadedFiles.value.length);
|
return !!(form.theme.trim() || form.highlights.trim() || uploadedFiles.value.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -375,6 +404,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const buildPayload = (): ManuscriptGenParams => {
|
const buildPayload = (): ManuscriptGenParams => {
|
||||||
|
if (!templateMode.value) {
|
||||||
|
// 自由创作:只发一段内容,后端见 docType 为空就转「AI 创意助手」
|
||||||
|
return {
|
||||||
|
docType: '',
|
||||||
|
userContent: form.freeContent.trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
docType: form.docType,
|
docType: form.docType,
|
||||||
eventType: form.eventType,
|
eventType: form.eventType,
|
||||||
@@ -718,7 +754,9 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
form.docType = '新闻稿';
|
// 重置回自由创作,而不是上一次的模板
|
||||||
|
form.docType = DOC_TYPE_FREE;
|
||||||
|
form.freeContent = '';
|
||||||
form.eventType = '校园活动';
|
form.eventType = '校园活动';
|
||||||
form.theme = '';
|
form.theme = '';
|
||||||
form.time = '';
|
form.time = '';
|
||||||
@@ -730,6 +768,12 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fillDemo = (type: DemoType) => {
|
const fillDemo = (type: DemoType) => {
|
||||||
|
if (type === 'free') {
|
||||||
|
form.docType = DOC_TYPE_FREE;
|
||||||
|
form.freeContent =
|
||||||
|
'帮我写一段迎新晚会的主持人开场词,口语化一点,时长两分钟左右,面向大一新生。';
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (type === 'news') {
|
if (type === 'news') {
|
||||||
form.theme = '广西医科大学青年志愿者开展“清洁校园”志愿服务活动';
|
form.theme = '广西医科大学青年志愿者开展“清洁校园”志愿服务活动';
|
||||||
form.docType = '新闻稿';
|
form.docType = '新闻稿';
|
||||||
@@ -805,7 +849,11 @@
|
|||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
if (!canSubmit.value) {
|
if (!canSubmit.value) {
|
||||||
message.warning('请至少填写稿件标题、亮点内容或上传附件中的一项');
|
message.warning(
|
||||||
|
templateMode.value
|
||||||
|
? '请至少填写稿件标题、亮点内容或上传附件中的一项'
|
||||||
|
: '请先描述你想创作的内容'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -814,7 +862,9 @@
|
|||||||
responseSummary.value = '正在建立 WebSocket 连接...';
|
responseSummary.value = '正在建立 WebSocket 连接...';
|
||||||
try {
|
try {
|
||||||
await connectWebSocket();
|
await connectWebSocket();
|
||||||
responseSummary.value = '连接成功,已提交生成任务,等待流式返回...';
|
responseSummary.value = templateMode.value
|
||||||
|
? '连接成功,已提交生成任务,等待流式返回...'
|
||||||
|
: '自由创作已提交给 AI 创意助手,生成完成后一次性返回...';
|
||||||
const waitStream = createStreamWaiter();
|
const waitStream = createStreamWaiter();
|
||||||
const result = await manuscriptGen(buildPayload());
|
const result = await manuscriptGen(buildPayload());
|
||||||
if (!streamStarted.value && shouldTreatAsDirectResult(result)) {
|
if (!streamStarted.value && shouldTreatAsDirectResult(result)) {
|
||||||
|
|||||||
@@ -1,231 +0,0 @@
|
|||||||
<!-- 用户编辑弹窗 -->
|
|
||||||
<template>
|
|
||||||
<div class="page">
|
|
||||||
<div class="ele-body" style="padding-bottom: 60px;">
|
|
||||||
<!-- 表格 -->
|
|
||||||
<a-spin :spinning="loading">
|
|
||||||
<table class="ele-table ele-table-stripe ele-table-medium">
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="(item,index) in notice" :key="index">
|
|
||||||
<td style="text-align: right; width: 360px; vertical-align: top;">
|
|
||||||
<div class="user-avatar">
|
|
||||||
<a-avatar v-if="item.channel === 0" :src="item.avatar" />
|
|
||||||
<a-avatar v-else class="openai" />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="content"><byte-md-viewer :value="item.content" ref="md-content" :plugins="plugins" /></div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a-button v-if="item.channel === 1" @click="copyText(item.content)">复制</a-button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</a-spin>
|
|
||||||
<a-space class="action">
|
|
||||||
<a-popconfirm
|
|
||||||
title="确定要删除当前会话吗?"
|
|
||||||
@confirm="remove(item)"
|
|
||||||
>
|
|
||||||
<a-button class="ele-text-danger">清空当前会话</a-button>
|
|
||||||
</a-popconfirm>
|
|
||||||
</a-space>
|
|
||||||
</div>
|
|
||||||
<a-card class="foot">
|
|
||||||
<div class="send-box">
|
|
||||||
<a-space>
|
|
||||||
<a-input
|
|
||||||
v-model:value="content"
|
|
||||||
placeholder="我是AI机器人,你想和我聊点什么?"
|
|
||||||
size="large"
|
|
||||||
:disabled="loading"
|
|
||||||
style="width: 770px"
|
|
||||||
@pressEnter="onSend"
|
|
||||||
>
|
|
||||||
<template #suffix>
|
|
||||||
<send-outlined @click="onSend" />
|
|
||||||
</template>
|
|
||||||
</a-input>
|
|
||||||
</a-space>
|
|
||||||
</div>
|
|
||||||
</a-card>
|
|
||||||
<a-back-top />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import "bytemd/dist/index.min.css";
|
|
||||||
import { ref } from "vue";
|
|
||||||
import "github-markdown-css/github-markdown-light.css";
|
|
||||||
// // 链接、删除线、复选框、表格等的插件
|
|
||||||
import gfm from "@bytemd/plugin-gfm";
|
|
||||||
// // 插件的中文语言文件
|
|
||||||
import zh_HansGfm from "@bytemd/plugin-gfm/locales/zh_Hans.json";
|
|
||||||
// 中文语言文件
|
|
||||||
import "bytemd/dist/index.min.css";
|
|
||||||
import highlight from "@bytemd/plugin-highlight-ssr";
|
|
||||||
import "highlight.js/styles/default.css";
|
|
||||||
import { message} from "ant-design-vue";
|
|
||||||
import { storeToRefs } from "pinia";
|
|
||||||
import { useThemeStore } from "@/store/modules/theme";
|
|
||||||
import { uploadFile } from "@/api/system/file";
|
|
||||||
import type { Notice } from "@/api/oa/notice/model";
|
|
||||||
import { FILE_SERVER } from "@/config/setting";
|
|
||||||
import type { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
|
|
||||||
import { listNotice, removeNotice } from "@/api/oa/notice";
|
|
||||||
import { chat, send } from "@/api/oa/chatgpt";
|
|
||||||
import {
|
|
||||||
SendOutlined
|
|
||||||
} from '@ant-design/icons-vue';
|
|
||||||
import { Message } from "@/api/user/message/model";
|
|
||||||
import { EquipmentFault } from "@/api/apps/equipment/fault/model";
|
|
||||||
import { removeEquipmentFault } from "@/api/apps/equipment/fault";
|
|
||||||
import { EleTableSelect } from "ele-admin-pro";
|
|
||||||
import { FormInstance } from "ant-design-vue/es/form";
|
|
||||||
import { copyText, isImage } from "@/utils/common";
|
|
||||||
|
|
||||||
// 插件
|
|
||||||
const plugins = ref([
|
|
||||||
gfm({
|
|
||||||
locale: zh_HansGfm
|
|
||||||
}),
|
|
||||||
highlight()
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 是否开启响应式布局
|
|
||||||
const themeStore = useThemeStore();
|
|
||||||
const { styleResponsive } = storeToRefs(themeStore);
|
|
||||||
// 请求状态
|
|
||||||
const loading = ref(false);
|
|
||||||
const noticeId = ref(0);
|
|
||||||
const content = ref('');
|
|
||||||
const files = ref<ItemType[]>([]);
|
|
||||||
const notice = ref<Notice[]>([]);
|
|
||||||
|
|
||||||
/* 上传事件 */
|
|
||||||
const uploadHandler = (file: File) => {
|
|
||||||
const item: ItemType = {
|
|
||||||
file,
|
|
||||||
uid: (file as any).uid,
|
|
||||||
name: file.name
|
|
||||||
};
|
|
||||||
if (!file.type.startsWith("image")) {
|
|
||||||
message.error("只能选择图片");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (file.size / 1024 / 1024 > 2) {
|
|
||||||
message.error("大小不能超过 2MB");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onUpload(item);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onUpload = (d: ItemType) => {
|
|
||||||
uploadFile(<File>d.file)
|
|
||||||
.then((result) => {
|
|
||||||
files.value.push({
|
|
||||||
uid: result.id,
|
|
||||||
url: FILE_SERVER + result.path,
|
|
||||||
name: isImage(result.path) ? 'image' : result.name,
|
|
||||||
status: "done"
|
|
||||||
});
|
|
||||||
message.success("上传成功");
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
message.error(e.message);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/* 向chatGPT提问 */
|
|
||||||
const onSend = () => {
|
|
||||||
send({
|
|
||||||
tenantId: Number(localStorage.getItem('tenantId')),
|
|
||||||
content: content.value
|
|
||||||
}).then((noticeId) => {
|
|
||||||
query();
|
|
||||||
onChat(noticeId);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const onChat = (noticeId) => {
|
|
||||||
chat({
|
|
||||||
tenantId: Number(localStorage.getItem('tenantId')),
|
|
||||||
content: content.value,
|
|
||||||
noticeId
|
|
||||||
}).then(() => {
|
|
||||||
query();
|
|
||||||
content.value = '';
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 删除单个 */
|
|
||||||
const remove = (row: Notice) => {
|
|
||||||
console.log(row);
|
|
||||||
const hide = message.loading('请求中..', 0);
|
|
||||||
removeNotice(row.noticeId)
|
|
||||||
.then((msg) => {
|
|
||||||
hide();
|
|
||||||
message.success(msg);
|
|
||||||
query();
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
hide();
|
|
||||||
message.error(e.message);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const query = () => {
|
|
||||||
listNotice({limit: 2})
|
|
||||||
.then((response) => {
|
|
||||||
notice.value = response
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
message.error(e.message);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
query();
|
|
||||||
</script>
|
|
||||||
<script lang="ts">
|
|
||||||
export default {
|
|
||||||
name: "ChatGPT"
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.content {
|
|
||||||
max-width: 1000px;
|
|
||||||
padding: 20px 0;
|
|
||||||
.edit-md {
|
|
||||||
margin-top: 50px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foot{
|
|
||||||
position: fixed;
|
|
||||||
width: 100%;
|
|
||||||
background-color: #f3f9ff;
|
|
||||||
bottom: 0;
|
|
||||||
height: 100px;
|
|
||||||
//display: flex;
|
|
||||||
//justify-content: center;
|
|
||||||
.send-box{
|
|
||||||
margin-left: 360px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.user-avatar{
|
|
||||||
padding: 20px 0;
|
|
||||||
}
|
|
||||||
.openai{
|
|
||||||
background-image: url("@/assets/icon/openai-avatar.jpeg");
|
|
||||||
background-size: 100%;
|
|
||||||
}
|
|
||||||
.markdown-body{
|
|
||||||
background: none;
|
|
||||||
}
|
|
||||||
.action{
|
|
||||||
padding: 20px 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
.copy-btn{display: flex; justify-content: flex-end;}
|
|
||||||
</style>
|
|
||||||
@@ -226,32 +226,20 @@
|
|||||||
>
|
>
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</button>
|
</button>
|
||||||
<span class="ratio-label">字数</span>
|
<template v-if="writingTemplateMode">
|
||||||
<button
|
<span class="ratio-label">字数</span>
|
||||||
v-for="item in WRITING_WORD_COUNT_OPTIONS"
|
<button
|
||||||
:key="item.value"
|
v-for="item in WRITING_WORD_COUNT_OPTIONS"
|
||||||
class="ratio-option"
|
:key="item.value"
|
||||||
type="button"
|
class="ratio-option"
|
||||||
:class="{ 'is-active': writingWordCount === item.value }"
|
type="button"
|
||||||
:disabled="loading"
|
:class="{ 'is-active': writingWordCount === item.value }"
|
||||||
@click="writingWordCount = item.value"
|
:disabled="loading"
|
||||||
>
|
@click="writingWordCount = item.value"
|
||||||
{{ item.label }}
|
>
|
||||||
</button>
|
{{ item.label }}
|
||||||
</div>
|
</button>
|
||||||
<div v-if="activeChatMode === 'image'" class="ratio-panel">
|
</template>
|
||||||
<span class="ratio-label">图片比例</span>
|
|
||||||
<button
|
|
||||||
v-for="item in imageRatioOptions"
|
|
||||||
:key="item.value"
|
|
||||||
class="ratio-option"
|
|
||||||
type="button"
|
|
||||||
:class="{ 'is-active': imageRatio === item.value }"
|
|
||||||
:disabled="loading"
|
|
||||||
@click="imageRatio = item.value"
|
|
||||||
>
|
|
||||||
{{ item.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="activeChatMode === 'image'" class="ratio-panel attach-panel">
|
<div v-if="activeChatMode === 'image'" class="ratio-panel attach-panel">
|
||||||
<span class="ratio-label">参考素材</span>
|
<span class="ratio-label">参考素材</span>
|
||||||
@@ -348,11 +336,10 @@ import type { AiChatList } from "@/api/ai/aiChatList/model";
|
|||||||
import { sendMessage, stopMessage } from "@/api/oa/chatgpt";
|
import { sendMessage, stopMessage } from "@/api/oa/chatgpt";
|
||||||
import { manuscriptGen } from "@/api/ai/manuscriptGen";
|
import { manuscriptGen } from "@/api/ai/manuscriptGen";
|
||||||
import {
|
import {
|
||||||
parseWritingRequest,
|
WRITING_DOC_TYPE_FREE,
|
||||||
WRITING_DOC_TYPE_OPTIONS,
|
WRITING_DOC_TYPE_OPTIONS,
|
||||||
WRITING_WORD_COUNT_OPTIONS
|
WRITING_WORD_COUNT_OPTIONS
|
||||||
} from "./writing-params";
|
} from "./writing-params";
|
||||||
import type { WritingOverrides } from "./writing-params";
|
|
||||||
import { creativeAssistant, uploadAiFile } from "@/api/ai/creativeAssistant";
|
import { creativeAssistant, uploadAiFile } from "@/api/ai/creativeAssistant";
|
||||||
import type { CreativeAssistantUploadFile } from "@/api/ai/creativeAssistant/model";
|
import type { CreativeAssistantUploadFile } from "@/api/ai/creativeAssistant/model";
|
||||||
import { API_BASE_URL } from "@/config/setting";
|
import { API_BASE_URL } from "@/config/setting";
|
||||||
@@ -404,7 +391,6 @@ const loading = ref(false);
|
|||||||
const stopping = ref(false);
|
const stopping = ref(false);
|
||||||
const content = ref("");
|
const content = ref("");
|
||||||
const activeChatMode = ref<ChatMode>("chat");
|
const activeChatMode = ref<ChatMode>("chat");
|
||||||
const imageRatio = ref("1:1");
|
|
||||||
/**
|
/**
|
||||||
* 图片模式的参考素材,对应 Dify「AI 创意助手」开始节点的 file-list 变量 `user_files`。
|
* 图片模式的参考素材,对应 Dify「AI 创意助手」开始节点的 file-list 变量 `user_files`。
|
||||||
*
|
*
|
||||||
@@ -416,8 +402,8 @@ const imageRatio = ref("1:1");
|
|||||||
const imageFiles = ref<CreativeAttachment[]>([]);
|
const imageFiles = ref<CreativeAttachment[]>([]);
|
||||||
const uploadingFiles = ref(false);
|
const uploadingFiles = ref(false);
|
||||||
const fileInputRef = ref<HTMLInputElement>();
|
const fileInputRef = ref<HTMLInputElement>();
|
||||||
// 写作模式的显式选择:默认 auto,即由 writing-params 从用户输入里自动识别
|
// 写作模式的显式选择:默认 auto = 不下发这两个字段的值,交给上游 Dify 识别
|
||||||
const writingDocType = ref("auto");
|
const writingDocType = ref(WRITING_DOC_TYPE_FREE);
|
||||||
const writingWordCount = ref("auto");
|
const writingWordCount = ref("auto");
|
||||||
const wsConnected = ref(false);
|
const wsConnected = ref(false);
|
||||||
const deletingChatId = ref<number>();
|
const deletingChatId = ref<number>();
|
||||||
@@ -462,13 +448,9 @@ const chatModeOptions: { label: string; value: ChatMode }[] = [
|
|||||||
{ label: "图片", value: "image" }
|
{ label: "图片", value: "image" }
|
||||||
];
|
];
|
||||||
|
|
||||||
const imageRatioOptions = [
|
// 图片比例选择器已撤掉:上游创意助手开始节点没有比例变量(实测只有 user_content /
|
||||||
{ label: "1:1", value: "1:1" },
|
// user_files),前端只能把它拼进 user_content —— 那是替上游写 prompt。
|
||||||
{ label: "4:3", value: "4:3" },
|
// 要恢复这个功能,先在上游开始节点加变量,再按变量契约接上来。
|
||||||
{ label: "3:4", value: "3:4" },
|
|
||||||
{ label: "16:9", value: "16:9" },
|
|
||||||
{ label: "9:16", value: "9:16" }
|
|
||||||
];
|
|
||||||
|
|
||||||
/** 参考素材上限,与 Dify 开始节点 user_files 的 max_length 一致 */
|
/** 参考素材上限,与 Dify 开始节点 user_files 的 max_length 一致 */
|
||||||
const IMAGE_FILE_LIMIT = 5;
|
const IMAGE_FILE_LIMIT = 5;
|
||||||
@@ -476,12 +458,17 @@ const IMAGE_FILE_EXTS = ["PNG", "JPG", "JPEG", "GIF", "WEBP", "BMP", "SVG"];
|
|||||||
const VIDEO_FILE_EXTS = ["MP4", "MOV", "MPEG", "MPGA", "AVI", "M4V"];
|
const VIDEO_FILE_EXTS = ["MP4", "MOV", "MPEG", "MPGA", "AVI", "M4V"];
|
||||||
const AUDIO_FILE_EXTS = ["MP3", "M4A", "WAV", "AMR", "AAC", "OGG", "WEBM"];
|
const AUDIO_FILE_EXTS = ["MP3", "M4A", "WAV", "AMR", "AAC", "OGG", "WEBM"];
|
||||||
|
|
||||||
|
/** 选中文体 = 套模板走「内容创作与宣传助手」;否则是自由创作走「AI 创意助手」 */
|
||||||
|
const writingTemplateMode = computed(
|
||||||
|
() => writingDocType.value !== WRITING_DOC_TYPE_FREE
|
||||||
|
);
|
||||||
|
|
||||||
const inputPlaceholder = computed(() => {
|
const inputPlaceholder = computed(() => {
|
||||||
if (activeChatMode.value === "writing") {
|
if (activeChatMode.value === "writing") {
|
||||||
return "直接描述需求即可,例如:帮我写一篇300字关于迎新晚会的推文,风格活泼,面向大一新生(也可在下方直接指定文体和字数)";
|
return "描述你想创作的内容,例如:帮我写一段迎新晚会的主持人开场词,口语化一点(选了文体后才需要再定字数)";
|
||||||
}
|
}
|
||||||
if (activeChatMode.value === "image") {
|
if (activeChatMode.value === "image") {
|
||||||
return "请输入图片生成需求,例如校园活动海报、公众号头图、志愿服务插画,并选择图片比例";
|
return "请输入图片生成需求,例如校园活动海报、公众号头图、志愿服务插画";
|
||||||
}
|
}
|
||||||
return "说说你想问的问题";
|
return "说说你想问的问题";
|
||||||
});
|
});
|
||||||
@@ -1026,23 +1013,27 @@ const pickString = (...values: unknown[]) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildWritingPayload = (query: string) => {
|
const buildWritingPayload = (query: string) => {
|
||||||
// 界面上显式选了文体/字数就用显式值,否则由 writing-params 从自然语言里识别。
|
// 两条路,都不解析自然语言(2026-10-21 改造):
|
||||||
// 过去这里没有表单,一律回落 doc_type=新闻稿 / Number=800,而上游提示词把这两项
|
// - 自由创作:原话整段作为 userContent,后端见 docType 为空就转「AI 创意助手」;
|
||||||
// 当作主控指令,会直接压掉用户原话里的诉求(实测见 .scratch/ai-assistant/probe/)。
|
// - 选了文体:原话整段作为 highlights,只带界面显式选过的 docType / number,
|
||||||
const overrides: WritingOverrides = {};
|
// 其余可选字段留空交给上游。doc_type / Number 是上游提示词的**主控指令**,
|
||||||
if (writingDocType.value !== "auto") {
|
// 本地猜会猜错(见 issue 13)。
|
||||||
overrides.docType = writingDocType.value;
|
const content = String(query || "").trim();
|
||||||
|
if (writingDocType.value === WRITING_DOC_TYPE_FREE) {
|
||||||
|
return { docType: "", userContent: content };
|
||||||
}
|
}
|
||||||
if (writingWordCount.value !== "auto") {
|
const payload: Record<string, unknown> = {
|
||||||
overrides.number = Number(writingWordCount.value);
|
docType: writingDocType.value,
|
||||||
}
|
highlights: content,
|
||||||
return {
|
|
||||||
...parseWritingRequest(query, overrides),
|
|
||||||
files: []
|
files: []
|
||||||
};
|
};
|
||||||
|
if (writingWordCount.value !== "auto") {
|
||||||
|
payload.number = Number(writingWordCount.value);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatWritingResult = (payload: unknown, query: string) => {
|
const formatWritingResult = (payload: unknown) => {
|
||||||
if (typeof payload === "string") {
|
if (typeof payload === "string") {
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
@@ -1076,7 +1067,9 @@ const formatWritingResult = (payload: unknown, query: string) => {
|
|||||||
data.choices?.[0]?.message?.content
|
data.choices?.[0]?.message?.content
|
||||||
);
|
);
|
||||||
if (title || body) {
|
if (title || body) {
|
||||||
return `${title || buildWritingPayload(query).theme}\n\n${body}`;
|
// 不再从入参里补标题:改造后没有「从原话抽主题」这一步,
|
||||||
|
// 拼一个空标题只会在正文前多两行空行。
|
||||||
|
return title ? `${title}\n\n${body}` : body;
|
||||||
}
|
}
|
||||||
// 不再 stringifyUnknown(payload):那会把 conversationId / taskId 这类
|
// 不再 stringifyUnknown(payload):那会把 conversationId / taskId 这类
|
||||||
// 内部标识符整包渲染给用户。
|
// 内部标识符整包渲染给用户。
|
||||||
@@ -1222,14 +1215,6 @@ const formatCreativeResult = (payload: unknown) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildImagePrompt = (query: string) => {
|
|
||||||
return [
|
|
||||||
query,
|
|
||||||
`图片比例:${imageRatio.value}`,
|
|
||||||
"请直接生成图片,优先输出图片文件或图片链接。"
|
|
||||||
].join("\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 按 MIME 或扩展名判定 Dify file-list 要求的 type(image/video/audio/document) */
|
/** 按 MIME 或扩展名判定 Dify file-list 要求的 type(image/video/audio/document) */
|
||||||
const resolveUploadFileType = (
|
const resolveUploadFileType = (
|
||||||
contentType: string,
|
contentType: string,
|
||||||
@@ -1309,7 +1294,13 @@ const onImageFilesPicked = async (event: Event) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 图片模式入参:user_content 一段文本 + user_files(Dify file-list 变量) */
|
/**
|
||||||
|
* 图片模式入参:`user_content` 就是**用户原话** + `user_files`(Dify file-list 变量)。
|
||||||
|
*
|
||||||
|
* 不往 `user_content` 里追加任何我们自己写的话(如「图片比例:1:1」
|
||||||
|
* 「请直接生成图片…」):上游创意助手只有 `user_content` / `user_files` 两个变量,
|
||||||
|
* 没有比例变量,拼进去等于替上游写 prompt。比例选择器已撤掉。
|
||||||
|
*/
|
||||||
const buildImagePayload = (query: string) => {
|
const buildImagePayload = (query: string) => {
|
||||||
const userFiles: CreativeAssistantUploadFile[] = imageFiles.value.map((item) => ({
|
const userFiles: CreativeAssistantUploadFile[] = imageFiles.value.map((item) => ({
|
||||||
transfer_method: "local_file",
|
transfer_method: "local_file",
|
||||||
@@ -1318,7 +1309,7 @@ const buildImagePayload = (query: string) => {
|
|||||||
url: ""
|
url: ""
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
user_content: buildImagePrompt(query),
|
user_content: query,
|
||||||
user_files: userFiles
|
user_files: userFiles
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1340,7 +1331,7 @@ const sendModeMessage = async (
|
|||||||
if (typeof (result as any)?.taskId === "string") {
|
if (typeof (result as any)?.taskId === "string") {
|
||||||
currentTaskId.value = (result as any).taskId;
|
currentTaskId.value = (result as any).taskId;
|
||||||
}
|
}
|
||||||
setResult(formatWritingResult(result, query));
|
setResult(formatWritingResult(result));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (activeChatMode.value === "image") {
|
if (activeChatMode.value === "image") {
|
||||||
|
|||||||
@@ -1,365 +1,44 @@
|
|||||||
/**
|
/**
|
||||||
* 「AI 写作助手」入参解析:把用户自然语言诉求抽取成上游 Dify 真正读取的字段。
|
* 「AI 写作助手」的可选项定义(2026-10-21 改造后)。
|
||||||
*
|
*
|
||||||
* 背景(实测记录见 .scratch/ai-assistant/probe/):
|
* 本文件与 `tuanwei-uniapp/src/pages/assistant/writing-params.js` 保持同一套常量,
|
||||||
* 上游应用「AI内容创作与宣传助手」是按**结构化表单**设计的,它的 user prompt 模板是
|
* 改动其一请同步另一侧。
|
||||||
*
|
*
|
||||||
* 本次需要生成的文稿类型为:{doc_type}
|
* 改造前这里还负责把用户原话解析成「键:值」入参,再据此填 doc_type / Number。
|
||||||
* 篇幅要求(字数限制):{Number}
|
* 现在写作模式只有两条路,都不解析自然语言:
|
||||||
* 以下是本次活动的核心要素,请严格据此生成稿件:
|
|
||||||
* - 活动主题:{theme}
|
|
||||||
* - 活动类型:{event_type}
|
|
||||||
* - 举办时间:{time} / 举办地点:{location} / 参与人员:{participants}
|
|
||||||
* - 核心亮点与具体要求:{highlights}
|
|
||||||
*
|
*
|
||||||
* 其中 doc_type 与 Number 是**主控指令**,模型会显式服从、甚至忽略用户原话里的冲突要求。
|
* 1. 自由创作(value = `auto`):原话整段作为 `userContent`,后端见 `docType` 为空
|
||||||
* 实测思维链原文:
|
* 就转「AI 创意助手」(只吃 `user_content`);
|
||||||
* I will ignore the conflicting "300字推文" instruction inside the bullet points
|
* 2. 选了文体:原话整段作为 `highlights`(核心亮点与具体要求),连同 doc_type
|
||||||
* because the main task says "文稿类型为:新闻稿" and "篇幅要求(字数限制):800"
|
* 交给「内容创作与宣传助手」,字数只认界面上的显式选择。
|
||||||
*
|
*
|
||||||
* 而聊天页的「写作」入口过去没有表单,只按「键:值」解析,解析不到就一律回落
|
* 为什么不猜:上游提示词里 doc_type / Number 是**主控指令**,模型会显式服从、
|
||||||
* doc_type=新闻稿 / Number=800,用户自然语言里的真实诉求(推文、300 字)因此被覆盖,
|
* 甚至压掉用户原话里的冲突要求(实测思维链:「I will ignore the conflicting
|
||||||
* 表现为「写作模块听不懂要求」。
|
* "300字推文" instruction … because the main task says "文稿类型为:新闻稿"」)。
|
||||||
|
* 从原话里推断还会在短祈使句上产出「帮我写个」这类残片当活动主题
|
||||||
|
* (见 issue 13 的「2026-10-19 契约修正」)。
|
||||||
*
|
*
|
||||||
* 本模块做三件事:
|
* 本模块只剩选项常量:纯数据、零依赖。
|
||||||
* 1. 保留既有的「键:值」入参(向后兼容,老用法不受影响);
|
|
||||||
* 2. 从自然语言里抽取文体、字数、主题、时间、地点、人员、活动类型;
|
|
||||||
* 3. 允许用户在界面上显式指定文体 / 字数,显式选择优先于自动识别。
|
|
||||||
*
|
|
||||||
* 纯函数、零依赖,可直接用 node 运行测试。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface WritingOverrides {
|
/** 「自由创作」的取值,同时也是 `writingDocType` 的默认值 */
|
||||||
/** 用户显式选择的文体;不传或传 "auto" 表示自动识别 */
|
export const WRITING_DOC_TYPE_FREE = 'auto';
|
||||||
docType?: string;
|
|
||||||
/** 用户显式选择的字数;不传表示自动识别 */
|
|
||||||
number?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WritingRequest {
|
|
||||||
docType: string;
|
|
||||||
eventType: string;
|
|
||||||
theme: string;
|
|
||||||
time: string;
|
|
||||||
location: string;
|
|
||||||
participants: string;
|
|
||||||
highlights: string;
|
|
||||||
number?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 上游 Dify 提示词里真正定义了 Markdown 结构与文风的 4 种文体 */
|
/** 上游 Dify 提示词里真正定义了 Markdown 结构与文风的 4 种文体 */
|
||||||
export const WRITING_DOC_TYPE_OPTIONS = [
|
export const WRITING_DOC_TYPE_OPTIONS = [
|
||||||
{ label: '自动识别', value: 'auto' },
|
{ label: '自由创作', value: WRITING_DOC_TYPE_FREE },
|
||||||
{ label: '新闻稿', value: '新闻稿' },
|
{ label: '新闻稿', value: '新闻稿' },
|
||||||
{ label: '活动总结', value: '活动总结' },
|
{ label: '活动总结', value: '活动总结' },
|
||||||
{ label: '微信推文', value: '微信推文' },
|
{ label: '微信推文', value: '微信推文' },
|
||||||
{ label: '通知公告', value: '通知公告' }
|
{ label: '通知公告', value: '通知公告' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 字数只在选了文体之后才显示;`auto` 表示不下发 */
|
||||||
export const WRITING_WORD_COUNT_OPTIONS = [
|
export const WRITING_WORD_COUNT_OPTIONS = [
|
||||||
{ label: '自动识别', value: 'auto' },
|
{ label: '默认', value: 'auto' },
|
||||||
{ label: '300字', value: '300' },
|
{ label: '300字', value: '300' },
|
||||||
{ label: '500字', value: '500' },
|
{ label: '500字', value: '500' },
|
||||||
{ label: '800字', value: '800' },
|
{ label: '800字', value: '800' },
|
||||||
{ label: '1000字', value: '1000' },
|
{ label: '1000字', value: '1000' },
|
||||||
{ label: '1500字', value: '1500' }
|
{ label: '1500字', value: '1500' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_DOC_TYPE = '新闻稿';
|
|
||||||
const DEFAULT_EVENT_TYPE = '校园内容';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 用户没提字数时的兜底:按文体给一个常规篇幅。
|
|
||||||
*
|
|
||||||
* 之所以不干脆不下发 Number —— 上游提示词对空字数的处理是「按文体常规篇幅撰写,
|
|
||||||
* 并在文末用一行注明(未设定有效字数限制)」,那行尾注会直接出现在用户看到的正文里。
|
|
||||||
*/
|
|
||||||
const DEFAULT_WORD_COUNT_BY_DOC_TYPE: Record<string, number> = {
|
|
||||||
新闻稿: 800,
|
|
||||||
活动总结: 1000,
|
|
||||||
微信推文: 600,
|
|
||||||
通知公告: 500
|
|
||||||
};
|
|
||||||
|
|
||||||
const FIELD_ALIASES: Record<string, string[]> = {
|
|
||||||
docType: ['稿件类型', '文稿类型', '文体', '类型'],
|
|
||||||
eventType: ['活动类型', '场景'],
|
|
||||||
theme: ['稿件标题', '标题', '主题', '活动主题'],
|
|
||||||
time: ['活动时间', '举办时间', '时间'],
|
|
||||||
location: ['活动地点', '举办地点', '地点'],
|
|
||||||
participants: ['参与人员', '参加人员', '受众', '人员'],
|
|
||||||
highlights: ['亮点内容', '要点', '亮点', '具体要求', '要求', '内容'],
|
|
||||||
number: ['建议篇幅', '字数', '篇幅']
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 活动类型关键词(与 gxmu/manuscriptGen 表单页的选项保持一致的措辞) */
|
|
||||||
const EVENT_TYPE_KEYWORDS = [
|
|
||||||
'志愿服务',
|
|
||||||
'社会实践',
|
|
||||||
'文体活动',
|
|
||||||
'会议论坛',
|
|
||||||
'品牌项目',
|
|
||||||
'校园活动',
|
|
||||||
'团日活动',
|
|
||||||
'迎新',
|
|
||||||
'讲座',
|
|
||||||
'培训',
|
|
||||||
'比赛',
|
|
||||||
'表彰',
|
|
||||||
'招聘',
|
|
||||||
'宣讲'
|
|
||||||
];
|
|
||||||
|
|
||||||
/** 既有的「键:值」解析:按行取 `键:值`,普通自然语言(无冒号)解析为空对象 */
|
|
||||||
export const parseKeyValueInput = (value: string) => {
|
|
||||||
const result: Record<string, string> = {};
|
|
||||||
value
|
|
||||||
.split(/\n+/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.forEach((line) => {
|
|
||||||
const match = line.match(/^([^::]+)[::]\s*(.*)$/);
|
|
||||||
if (match) {
|
|
||||||
result[match[1].trim()] = match[2].trim();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pickField = (fields: Record<string, string>, aliases: string[]) => {
|
|
||||||
for (const alias of aliases) {
|
|
||||||
const value = fields[alias];
|
|
||||||
if (value) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 上游没有内置模板、但用户会说的其它文体;命中就原样交给模型发挥 */
|
|
||||||
const EXTRA_DOC_TYPE_WORDS = [
|
|
||||||
'发言稿',
|
|
||||||
'演讲稿',
|
|
||||||
'致辞',
|
|
||||||
'主持词',
|
|
||||||
'短视频脚本',
|
|
||||||
'视频脚本',
|
|
||||||
'脚本',
|
|
||||||
'策划书',
|
|
||||||
'活动方案',
|
|
||||||
'简报',
|
|
||||||
'心得体会'
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从一段文本里识别文体关键词,认不出来返回空串。
|
|
||||||
*
|
|
||||||
* 只认「明确的文体词」,不做任何兜底 —— 它会被喂进用户的**整句话**,
|
|
||||||
* 若像 {@link normalizeDocType} 那样原样返回,整句话就会变成一个非法的文稿类型。
|
|
||||||
*/
|
|
||||||
export const inferDocType = (text: string) => {
|
|
||||||
if (/推文|公众号|微信|朋友圈|新媒体/.test(text)) {
|
|
||||||
return '微信推文';
|
|
||||||
}
|
|
||||||
if (/总结|复盘|综述/.test(text)) {
|
|
||||||
return '活动总结';
|
|
||||||
}
|
|
||||||
if (/通知|公告|告示|通告|启事/.test(text)) {
|
|
||||||
return '通知公告';
|
|
||||||
}
|
|
||||||
if (/新闻|通讯|通稿|报道|简讯|宣传稿|消息稿/.test(text)) {
|
|
||||||
return '新闻稿';
|
|
||||||
}
|
|
||||||
for (const word of EXTRA_DOC_TYPE_WORDS) {
|
|
||||||
if (text.includes(word)) {
|
|
||||||
return word;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把用户给出的文体归一化到上游定义过的 4 种。
|
|
||||||
*
|
|
||||||
* 认不出来的类型(如「发言稿」「短视频脚本」)**原样返回**,交给模型自行发挥 ——
|
|
||||||
* 强行映射成「新闻稿」会生成一篇完全不是发言稿的文章,比不映射更糟。
|
|
||||||
* 注意:入参应当是「文体名」(键值或界面选择),不要传用户整句话。
|
|
||||||
*/
|
|
||||||
export const normalizeDocType = (raw: string) => {
|
|
||||||
const value = (raw || '').trim();
|
|
||||||
if (!value) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return inferDocType(value) || value;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从文本里抽取目标字数。
|
|
||||||
*
|
|
||||||
* 覆盖「300字」「300字左右」「不少于500字」「800-1000字」等写法;
|
|
||||||
* 区间取上界(用户给区间时通常不希望写少)。50~10000 之外视为无效。
|
|
||||||
*/
|
|
||||||
export const parseWordCount = (text: string): number | undefined => {
|
|
||||||
const valid = (raw: string | undefined) => {
|
|
||||||
if (!raw) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const value = Number(raw);
|
|
||||||
return value >= 50 && value <= 10000 ? value : undefined;
|
|
||||||
};
|
|
||||||
// 「键:值」入参里的字数常常就是一个裸数字(字数:500)
|
|
||||||
const bare = text.trim().match(/^(\d{2,5})\s*(?:字|字符)?$/);
|
|
||||||
if (bare) {
|
|
||||||
return valid(bare[1]);
|
|
||||||
}
|
|
||||||
const range = text.match(
|
|
||||||
/(\d{2,5})\s*[-~~—-至到]\s*(\d{2,5})\s*(?:字|字符)/
|
|
||||||
);
|
|
||||||
if (range) {
|
|
||||||
return valid(range[2]);
|
|
||||||
}
|
|
||||||
const single = text.match(/(\d{2,5})\s*(?:字|字符)/);
|
|
||||||
if (single) {
|
|
||||||
return valid(single[1]);
|
|
||||||
}
|
|
||||||
const target = text.match(
|
|
||||||
/(?:不少于|不低于|至少|不超过|大约|约|控制在|限制在|需要|要)\s*(\d{2,5})\s*(?:字|字符)?/
|
|
||||||
);
|
|
||||||
return valid(target?.[1]);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 从「关于X的」「以X为主题」「写一篇X总结」等说法里抽取活动主题 */
|
|
||||||
export const parseTheme = (query: string) => {
|
|
||||||
const patterns = [
|
|
||||||
/关于[「“"'《]?([^,。;!?\n]{2,40}?)[」”"'》]?的/,
|
|
||||||
/以[「“"'《]?([^,。;!?\n]{2,40}?)[」”"'》]?(?:为主题|为题|为标题)/,
|
|
||||||
/(?:写|撰写|生成|创作|拟写|草拟|做)(?:一篇|一份|一段|一个|一则|条)?[「“"'《]?([^,。;!?\n]{2,40}?)[」”"'》]?(?:上的|中的|里的|的|之)?(?:新闻稿|通讯稿|通稿|报道|推文|总结|通知|公告|文案|稿件|宣传稿|发言稿|脚本|简讯)/,
|
|
||||||
/([^,。;!?\n]{2,30}?)(?:活动)?(?:总结|新闻稿|推文|通知|公告|报道|简讯)/
|
|
||||||
];
|
|
||||||
for (const pattern of patterns) {
|
|
||||||
const match = query.match(pattern);
|
|
||||||
const value = match?.[1]?.trim();
|
|
||||||
if (value) {
|
|
||||||
return tidyTheme(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 主题清洗:把抽到的短语里混进来的时间、地点、指令动词和助词去掉。
|
|
||||||
* 例如「2026年3月5日在学校礼堂举办的学习雷锋志愿服务活动」→「学习雷锋志愿服务活动」。
|
|
||||||
*/
|
|
||||||
const tidyTheme = (value: string) => {
|
|
||||||
const cleaned = value
|
|
||||||
.replace(/^\s*(?:\d{4}\s*年)?\s*\d{1,2}\s*月\s*\d{1,2}\s*[日号]\s*/, '')
|
|
||||||
.replace(
|
|
||||||
/^\s*(?:在|于)[^,。;!?\n]{2,20}?(?:举办|举行|开展|召开|进行)的?/,
|
|
||||||
''
|
|
||||||
)
|
|
||||||
.replace(/^\s*(?:开展|举办|举行|组织|进行|召开)/, '')
|
|
||||||
.replace(/(?:上的|中的|里的|的)$/, '')
|
|
||||||
.replace(/[,。;!?、\s]+$/, '')
|
|
||||||
.trim();
|
|
||||||
return cleaned || value.trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 兜底主题:清洗掉指令性词汇与字数要求后截断 */
|
|
||||||
const fallbackTheme = (query: string) => {
|
|
||||||
const cleaned = query
|
|
||||||
.replace(/[\r\n]+/g, ' ')
|
|
||||||
.replace(
|
|
||||||
/(?:请|帮我|麻烦|辛苦|需要)?\s*(?:写|撰写|生成|创作|拟写|草拟|出一?)(?:一篇|一份|一段|一个|条)?/g,
|
|
||||||
''
|
|
||||||
)
|
|
||||||
.replace(/(?:关于|围绕|以)/g, '')
|
|
||||||
.replace(
|
|
||||||
/\d{2,5}\s*(?:[-~~—-至到]\s*\d{2,5})?\s*(?:字|字符)(?:左右|以内|以下|以上)?/g,
|
|
||||||
''
|
|
||||||
)
|
|
||||||
.replace(/[,。;!?、,.;!?]/g, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
return (cleaned || query.trim()).slice(0, 30);
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseTime = (query: string) => {
|
|
||||||
const match = query.match(/(\d{4}\s*年)?\s*\d{1,2}\s*月\s*\d{1,2}\s*[日号]/);
|
|
||||||
return match ? match[0].replace(/\s+/g, '') : '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseLocation = (query: string) => {
|
|
||||||
const match = query.match(
|
|
||||||
/(?:在|于)\s*([^,。;!?\n]{2,20}?)\s*(?:举办|举行|开展|召开|进行)/
|
|
||||||
);
|
|
||||||
return match?.[1]?.trim() || '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseParticipants = (query: string) => {
|
|
||||||
const match = query.match(/面向\s*([^,。;!?\n]{2,20})/);
|
|
||||||
return match?.[1]?.trim() || '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseEventType = (query: string) => {
|
|
||||||
for (const keyword of EVENT_TYPE_KEYWORDS) {
|
|
||||||
if (query.includes(keyword)) {
|
|
||||||
return keyword;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把用户输入解析成上游工作流的入参。
|
|
||||||
*
|
|
||||||
* @param query 用户在写作框里输入的内容(自然语言或「键:值」)
|
|
||||||
* @param overrides 界面上的显式选择,优先于自动识别
|
|
||||||
*/
|
|
||||||
export const parseWritingRequest = (
|
|
||||||
query: string,
|
|
||||||
overrides: WritingOverrides = {}
|
|
||||||
): WritingRequest => {
|
|
||||||
const fields = parseKeyValueInput(query);
|
|
||||||
|
|
||||||
const explicitDocType =
|
|
||||||
overrides.docType && overrides.docType !== 'auto' ? overrides.docType : '';
|
|
||||||
const keyedDocType = normalizeDocType(
|
|
||||||
pickField(fields, FIELD_ALIASES.docType)
|
|
||||||
);
|
|
||||||
const inferredDocType = inferDocType(query) || DEFAULT_DOC_TYPE;
|
|
||||||
const docType = explicitDocType || keyedDocType || inferredDocType;
|
|
||||||
|
|
||||||
const explicitNumber =
|
|
||||||
typeof overrides.number === 'number' && overrides.number > 0
|
|
||||||
? overrides.number
|
|
||||||
: undefined;
|
|
||||||
const keyedNumber = parseWordCount(pickField(fields, FIELD_ALIASES.number));
|
|
||||||
const inferredNumber = parseWordCount(query);
|
|
||||||
const number =
|
|
||||||
explicitNumber ??
|
|
||||||
keyedNumber ??
|
|
||||||
inferredNumber ??
|
|
||||||
DEFAULT_WORD_COUNT_BY_DOC_TYPE[docType] ??
|
|
||||||
800;
|
|
||||||
|
|
||||||
const theme =
|
|
||||||
pickField(fields, FIELD_ALIASES.theme) ||
|
|
||||||
parseTheme(query) ||
|
|
||||||
fallbackTheme(query);
|
|
||||||
|
|
||||||
return {
|
|
||||||
docType,
|
|
||||||
eventType:
|
|
||||||
pickField(fields, FIELD_ALIASES.eventType) ||
|
|
||||||
parseEventType(query) ||
|
|
||||||
DEFAULT_EVENT_TYPE,
|
|
||||||
theme,
|
|
||||||
time: pickField(fields, FIELD_ALIASES.time) || parseTime(query),
|
|
||||||
location: pickField(fields, FIELD_ALIASES.location) || parseLocation(query),
|
|
||||||
participants:
|
|
||||||
pickField(fields, FIELD_ALIASES.participants) || parseParticipants(query),
|
|
||||||
// 「核心亮点与具体要求」保留用户原话:这里放原话比放抽取结果更贴合上游用途
|
|
||||||
highlights: pickField(fields, FIELD_ALIASES.highlights) || query,
|
|
||||||
number
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user