Explorar o código

feat: 添加空白流程引导弹窗,调整模型参数

jiaxing.liao hai 1 semana
pai
achega
2d7e257929

+ 2 - 6
apps/web/src/views/chat/composables/useChatStream.ts

@@ -114,8 +114,7 @@ export function useChatStream() {
 		}
 
 		try {
-			const token =
-				localStorage.getItem('oauth2token') ||
+			const token = // localStorage.getItem('oauth2token') ||
 				document.cookie.match(new RegExp('(^| )' + 'x-sessionId' + '=([^;]*)(;|$)'))?.[2]
 
 			// dev读取环境变量 prod使用当前
@@ -157,10 +156,7 @@ export function useChatStream() {
 				throw new Error('Response stream ended before complete')
 			}
 		} catch (error: any) {
-			if (
-				error?.name === 'AbortError' &&
-				(completeReceived || cancelledStreamIds.has(streamId))
-			) {
+			if (error?.name === 'AbortError' && (completeReceived || cancelledStreamIds.has(streamId))) {
 				finishStream()
 			} else if (error?.name !== 'AbortError') {
 				onError(await normalizeRequestError(error))

+ 41 - 1
apps/web/src/views/editor/NodeView.vue

@@ -1,6 +1,6 @@
 <template>
 	<div
-		class="h-full w-full"
+		class="relative h-full w-full"
 		ref="workflowWrapperRef"
 		@drop="onDrop"
 		@contextmenu="handleWorkflowContextMenu"
@@ -38,6 +38,11 @@
 				@change-env-vars="handleChangeEnvVars"
 			/>
 		</Workflow>
+		<StartNodeGuide
+			v-if="showStartNodeGuide"
+			@create-node="handleStartNodeGuideCreate"
+			@close="hideStartNodeGuide"
+		/>
 	</div>
 	<RunWorkflow
 		v-model:visible="runVisible"
@@ -131,6 +136,7 @@ import ChatDrawer from '@/features/ChatDrawer/index.vue'
 import NodeLibary from '@/features/nodeLibary/index.vue'
 import Toolbar from '@/features/toolbar/index.vue'
 import Setter from '@/features/setter/index.vue'
+import StartNodeGuide from './StartNodeGuide.vue'
 import { nodeMap } from '@/nodes'
 import { getNodeDisplayName } from '@/nodes/i18n'
 import { useI18n } from '@/composables/useI18n'
@@ -197,6 +203,7 @@ const contextMenuRef = ref<HTMLElement>()
 const contextMenuVisible = ref(false)
 const contextMenuPosition = ref({ x: 0, y: 0 })
 const contextMenuFlowPosition = ref<XYPosition>({ x: 0, y: 0 })
+const dismissedStartNodeGuideWorkflowId = ref('')
 const runVisible = ref(false)
 const closeRunWorkflowOnSubmit = ref(false)
 const runWorkflowInputOnly = ref(false)
@@ -239,6 +246,18 @@ const contextMenuStyle = computed(() => ({
 	top: `${contextMenuPosition.value.y}px`
 }))
 
+const hasWorkflowNodes = computed(() => {
+	return !!(props.workflow?.nodes?.length || pendingNodes.value.length)
+})
+
+const showStartNodeGuide = computed(() => {
+	return (
+		!!props.workflow?.id &&
+		!hasWorkflowNodes.value &&
+		dismissedStartNodeGuideWorkflowId.value !== props.workflow.id
+	)
+})
+
 const removeNodeLibaryPopoverAnchor = () => {
 	nodeLibaryPopoverAnchorRef.value?.remove()
 	nodeLibaryPopoverAnchorRef.value = undefined
@@ -786,6 +805,27 @@ const createStickyNoteNode = (position: XYPosition = { x: 600, y: 300 }) => {
 	})
 }
 
+const getViewportCenterFlowPosition = (): XYPosition | undefined => {
+	const viewport = workflowRef.value?.getVueFlow()?.viewport
+	if (!viewport) {
+		return undefined
+	}
+
+	return {
+		x: (-viewport.value.x + window.innerWidth / 2) / viewport.value.zoom,
+		y: (-viewport.value.y + window.innerHeight / 2) / viewport.value.zoom
+	}
+}
+
+const hideStartNodeGuide = () => {
+	dismissedStartNodeGuideWorkflowId.value = props.workflow?.id || ''
+}
+
+const handleStartNodeGuideCreate = (type: string) => {
+	const position = getViewportCenterFlowPosition()
+	handleNodeCreate(position ? { type, position } : { type })
+}
+
 watch(
 	() => props.workflow?.nodes,
 	async (nodes) => {

+ 206 - 0
apps/web/src/views/editor/StartNodeGuide.vue

@@ -0,0 +1,206 @@
+<template>
+	<el-dialog
+		:model-value="true"
+		width="740px"
+		class="start-node-guide-dialog"
+		:close-on-click-modal="false"
+		append-to-body
+		@close="$emit('close')"
+		align-center
+	>
+		<template #header>
+			<div class="start-node-guide__header">
+				<h2>选择开始节点来开始</h2>
+			</div>
+		</template>
+		<p>不同的开始节点具有不同的功能,您随时可以更改它们。</p>
+		<div class="start-node-guide__cards">
+			<button
+				v-for="item in startNodes"
+				:key="item.type"
+				type="button"
+				class="start-node-guide__card"
+				@click="$emit('create-node', item.type)"
+			>
+				<span class="start-node-guide__icon" :style="{ background: item.color }">
+					<Icon :icon="item.icon" width="18" height="18" />
+				</span>
+				<span class="start-node-guide__card-title">{{ item.title }}</span>
+				<span class="start-node-guide__card-desc">{{ item.description }}</span>
+			</button>
+		</div>
+	</el-dialog>
+</template>
+
+<script setup lang="ts">
+import { Icon } from '@repo/ui'
+
+defineEmits<{
+	'create-node': [type: string]
+	close: []
+}>()
+
+const startNodes = [
+	{
+		type: 'start',
+		title: '用户输入(原始开始节点)',
+		description:
+			'允许设置用户输入变量的开始节点,具有 Web 应用程序、服务 API、MCP 服务器和工作流即工具功能。',
+		icon: 'lucide:home',
+		color: '#0b74ff'
+	},
+	{
+		type: 'trigger-schedule',
+		title: '定时触发',
+		description: '按小时、每天、每周或每月定时运行工作流,适合周期任务和定时同步。',
+		icon: 'lucide:clock',
+		color: '#7c3aed'
+	},
+	{
+		type: 'trigger-webhook',
+		title: 'Webhook 触发',
+		description: '通过自定义 webhook 接收外部请求并启动工作流,适合与其他应用程序集成。',
+		icon: 'lucide:webhook',
+		color: '#0b74ff'
+	}
+]
+</script>
+
+<style scoped lang="less">
+.start-node-guide__header {
+	display: inline-flex;
+	width: 100%;
+}
+
+.start-node-guide__header h2 {
+	margin: 0;
+	color: #101828;
+	font-size: 20px;
+	font-weight: 700;
+	line-height: 1.3;
+	letter-spacing: -0.02em;
+}
+
+.start-node-guide__header p {
+	margin: 6px 0 0;
+	color: #667085;
+	font-size: 13px;
+	font-weight: 500;
+	line-height: 1.5;
+}
+
+.start-node-guide__close {
+	flex: 0 0 auto;
+	display: inline-flex;
+	align-items: center;
+	justify-content: center;
+	width: 30px;
+	height: 30px;
+	border: 1px solid #d5dce8;
+	border-radius: 50%;
+	background: #f8fafc;
+	color: #667085;
+	cursor: pointer;
+	transition:
+		background 0.18s ease,
+		border-color 0.18s ease,
+		color 0.18s ease,
+		transform 0.18s ease;
+}
+
+.start-node-guide__close:hover {
+	border-color: #b8c2d6;
+	background: #eef4ff;
+	color: #1d2939;
+	transform: translateY(-1px);
+}
+
+.start-node-guide__cards {
+	display: grid;
+	grid-template-columns: repeat(3, minmax(0, 1fr));
+	gap: 12px;
+}
+
+.start-node-guide__card {
+	display: flex;
+	min-height: 160px;
+	flex-direction: column;
+	align-items: flex-start;
+	padding: 16px;
+	border: 1px solid #e2e8f0;
+	border-radius: 14px;
+	background: #fff;
+	box-shadow: 0 4px 14px rgb(15 23 42 / 9%);
+	text-align: left;
+	cursor: pointer;
+	transition:
+		border-color 0.18s ease,
+		box-shadow 0.18s ease,
+		transform 0.18s ease;
+}
+
+.start-node-guide__card:hover {
+	border-color: #9cc3ff;
+	box-shadow: 0 10px 22px rgb(41 109 255 / 14%);
+	transform: translateY(-2px);
+}
+
+.start-node-guide__icon {
+	display: inline-flex;
+	align-items: center;
+	justify-content: center;
+	width: 38px;
+	height: 38px;
+	border-radius: 10px;
+	color: #fff;
+}
+
+.start-node-guide__card-title {
+	margin-top: 14px;
+	color: #101828;
+	font-size: 16px;
+	font-weight: 700;
+	line-height: 1.3;
+	letter-spacing: -0.02em;
+}
+
+.start-node-guide__card-desc {
+	margin-top: 6px;
+	color: #667085;
+	font-size: 12px;
+	font-weight: 500;
+	line-height: 1.45;
+}
+
+@media (max-width: 760px) {
+	.start-node-guide__cards {
+		grid-template-columns: 1fr;
+	}
+
+	.start-node-guide__card {
+		min-height: auto;
+	}
+}
+
+@media (max-width: 520px) {
+	.start-node-guide__header h2 {
+		font-size: 18px;
+	}
+
+	.start-node-guide__header p {
+		font-size: 12px;
+	}
+}
+
+:global(.start-node-guide-dialog .el-dialog__header) {
+	padding: 24px 24px 14px;
+}
+
+:global(.start-node-guide-dialog .el-dialog__body) {
+	padding: 0 24px 24px;
+}
+
+:global(.start-node-guide-dialog.el-dialog) {
+	max-width: calc(100vw - 32px);
+}
+</style>

+ 133 - 5
apps/web/src/views/model/index.vue

@@ -321,6 +321,34 @@
 					<el-switch v-model="modelForm.supports_vision" />
 				</el-form-item>
 
+				<el-form-item
+					v-if="modelForm.source === 'remote' && modelForm.type === 'KnowledgeQA'"
+					label="思考模式参数格式"
+					prop="thinking_control"
+				>
+					<el-select
+						v-model="modelForm.thinking_control"
+						placeholder="请选择思考模式参数格式"
+						popper-class="thinking-control-select"
+						style="width: 100%"
+					>
+						<el-option
+							v-for="option in thinkingControlOptions"
+							:key="option.value"
+							:label="option.title"
+							:value="option.value"
+						>
+							<div class="thinking-option">
+								<div class="thinking-option__title">{{ option.title }}</div>
+								<div class="thinking-option__desc">{{ option.description }}</div>
+							</div>
+						</el-option>
+					</el-select>
+					<div class="field-tip">
+						决定智能体「思考模式」开/关时如何写入 API。已尝试按厂商/模型预选,若与实际情况不符请按 API 文档手动修改;选「不写入」时,智能体「思考模式」开关不生效。
+					</div>
+				</el-form-item>
+
 				<el-form-item prop="custom_headers">
 					<div class="header-config">
 						<div class="header-config__top">
@@ -391,7 +419,8 @@ import type {
 	ModelCreateForm,
 	ModelDetail,
 	OllamaModel,
-	modelType
+	modelType,
+	ThinkingControlType
 } from './types'
 
 const props = withDefaults(
@@ -414,6 +443,32 @@ const currentDetailModel = ref<ModelDetail | null>(null)
 const modelFormRef = ref()
 const customHeaderList = ref<Array<{ key: string; value: string }>>([{ key: '', value: '' }])
 const localModelOptions = ref<OllamaModel[]>([])
+const thinkingControlOptions: Array<{
+	title: string
+	description: string
+	value: ThinkingControlType
+}> = [
+	{
+		title: '不写入思考参数',
+		description: '智能体「思考模式」开关不生效,不会在请求中写入思考相关参数',
+		value: 'none'
+	},
+	{
+		title: 'chat_template_kwargs',
+		description: '自定义 OpenAI 兼容、NVIDIA NIM、vLLM / 本地 Qwen 部署',
+		value: 'chat_template_kwargs'
+	},
+	{
+		title: 'enable_thinking',
+		description: '阿里云 DashScope:qwen3、qwen-plus、qwen-max、qwen-turbo',
+		value: 'enable_thinking'
+	},
+	{
+		title: 'thinking.type',
+		description: '火山引擎 Ark;腾讯云 LKEAP(DeepSeek V3 等,选 LKEAP 时默认此项;R1 请改「不写入」)',
+		value: 'thinking_type'
+	}
+]
 const detailCheckLoading = ref(false)
 const detailCheckResult = reactive<{ success: boolean; message: string }>({
 	success: false,
@@ -451,7 +506,8 @@ const modelForm = reactive<ModelCreateForm>({
 	custom_headers: {},
 	dimension: undefined,
 	truncate_prompt_tokens: undefined,
-	supports_vision: false
+	supports_vision: false,
+	thinking_control: 'none'
 })
 
 const modelRules = {
@@ -672,6 +728,11 @@ async function openEditModel(id: string) {
 			key,
 			value: String(value ?? '')
 		}))
+		const extraConfig = ((data as any)?.parameters?.extra_config ||
+			(data as any)?.extra_config ||
+			{}) as {
+			thinking_control?: ThinkingControlType
+		}
 		customHeaderList.value = headerRows.length ? headerRows : [{ key: '', value: '' }]
 		Object.assign(modelForm, {
 			source: data.source,
@@ -686,6 +747,7 @@ async function openEditModel(id: string) {
 			dimension: data.parameters?.embedding_parameters?.dimension,
 			truncate_prompt_tokens: data.parameters?.embedding_parameters?.truncate_prompt_tokens,
 			supports_vision: Boolean((data as any)?.parameters?.supports_vision),
+			thinking_control: extraConfig.thinking_control || 'none',
 			is_default: data.is_default
 		})
 	}
@@ -709,6 +771,7 @@ function resetModelForm() {
 		dimension: undefined,
 		truncate_prompt_tokens: undefined,
 		supports_vision: false,
+		thinking_control: 'none',
 		is_default: false
 	})
 }
@@ -727,6 +790,7 @@ function handleSourceChange() {
 	modelForm.name = ''
 	modelForm.provider = ''
 	modelForm.base_url = ''
+	modelForm.thinking_control = 'none'
 	resetFormCheckResult()
 }
 
@@ -740,9 +804,11 @@ async function handleTypeChange() {
 	}
 	if (modelForm.type === 'KnowledgeQA') {
 		modelForm.supports_vision = modelForm.supports_vision ?? false
+		modelForm.thinking_control = getDefaultThinkingControl(modelForm.provider)
 		return
 	}
 	modelForm.supports_vision = false
+	modelForm.thinking_control = 'none'
 	modelForm.dimension = undefined
 	modelForm.truncate_prompt_tokens = undefined
 	// getProviders(modelForm.type)
@@ -751,9 +817,29 @@ async function handleTypeChange() {
 function handleProviderChange() {
 	const provider = providers.value.find((p) => p.value === modelForm.provider)
 	if (provider) modelForm.base_url = getDefaultUrlByType(provider) || ''
+	modelForm.thinking_control = getDefaultThinkingControl(modelForm.provider)
 	resetFormCheckResult()
 }
 
+function getDefaultThinkingControl(provider?: string): ThinkingControlType {
+	const providerValue = (provider || '').toLowerCase()
+	if (providerValue.includes('dashscope') || providerValue.includes('aliyun')) {
+		return 'enable_thinking'
+	}
+	if (
+		providerValue.includes('volc') ||
+		providerValue.includes('ark') ||
+		providerValue.includes('lkeap') ||
+		providerValue.includes('tencent')
+	) {
+		return 'thinking_type'
+	}
+	if (providerValue.includes('nvidia') || providerValue.includes('nim') || providerValue.includes('vllm')) {
+		return 'chat_template_kwargs'
+	}
+	return 'none'
+}
+
 function getDefaultUrlByType(provider: ModelProvider) {
 	const typeToDefaultUrlKey: Record<string, keyof ModelProvider['defaultUrls']> = {
 		KnowledgeQA: 'chat',
@@ -882,13 +968,19 @@ async function submitModelForm() {
 							: false,
 				dimension: modelForm.type === 'Embedding' ? modelForm.dimension : undefined,
 				truncate_prompt_tokens:
-					modelForm.type === 'Embedding' ? modelForm.truncate_prompt_tokens : undefined
+					modelForm.type === 'Embedding' ? modelForm.truncate_prompt_tokens : undefined,
+				extra_config: {
+					thinking_control:
+						modelForm.source === 'remote' && modelForm.type === 'KnowledgeQA'
+							? modelForm.thinking_control
+							: 'none'
+				}
 			}
 			if (currentModelId.value) {
-				await aiModel.postModelUpdate({ id: currentModelId.value, ...params })
+				await aiModel.postModelUpdate({ id: currentModelId.value, ...params } as any)
 				ElMessage.success('更新成功')
 			} else {
-				await aiModel.postModelCreate(params)
+				await aiModel.postModelCreate(params as any)
 				ElMessage.success('创建成功')
 			}
 			showModelDialog.value = false
@@ -960,6 +1052,42 @@ onMounted(() => {
 	width: 100%;
 }
 
+.field-tip {
+	margin-top: 8px;
+	color: var(--text-tertiary);
+	font-size: 12px;
+	line-height: 1.6;
+}
+
+.thinking-option {
+	display: flex;
+	flex-direction: column;
+	justify-content: center;
+	min-height: 54px;
+	padding: 6px 0;
+	line-height: 1.35;
+}
+
+.thinking-option__title {
+	color: var(--text-primary);
+	font-size: 13px;
+	font-weight: 600;
+}
+
+.thinking-option__desc {
+	margin-top: 3px;
+	color: var(--text-tertiary);
+	font-size: 12px;
+	white-space: normal;
+}
+
+:global(.thinking-control-select .el-select-dropdown__item) {
+	height: auto;
+	min-height: 62px;
+	padding-top: 4px;
+	padding-bottom: 4px;
+}
+
 .action-bar {
 	display: flex;
 	align-items: center;

+ 10 - 0
apps/web/src/views/model/types.d.ts

@@ -1,4 +1,9 @@
 export type modelType = 'KnowledgeQA' | 'Embedding' | 'Rerank' | 'VLLM'
+export type ThinkingControlType =
+	| 'none'
+	| 'chat_template_kwargs'
+	| 'enable_thinking'
+	| 'thinking_type'
 
 export interface OllamaModel {
 	name?: string
@@ -59,6 +64,8 @@ export interface ModelCreateForm {
 	truncate_prompt_tokens?: number
 	/**支持视觉 对话模型、视觉模型、ollama模型 */
 	supports_vision?: boolean
+	/** 思考模式参数格式 */
+	thinking_control: ThinkingControlType
 }
 
 export interface ModelDetail {
@@ -78,6 +85,9 @@ export interface ModelDetail {
 			truncate_prompt_tokens: number
 		}
 		provider: string
+		extra_config?: {
+			thinking_control?: ThinkingControlType
+		}
 	}
 	provider: string
 	source: string

+ 1 - 18
packages/api-client/src/hook-fetch-client.ts

@@ -26,20 +26,6 @@ interface InterceptorsConfig {
 
 // --- 辅助函数 ---
 
-function getAuthToken(): string | null {
-	if (typeof window === 'undefined') return null
-	const token = localStorage.getItem('oauth2token')
-
-	if (token) {
-		try {
-			return JSON.parse(token) || token
-		} catch (e) {
-			return token
-		}
-	}
-	return null
-}
-
 function sleep(ms: number): Promise<void> {
 	return new Promise((resolve) => setTimeout(resolve, ms))
 }
@@ -111,11 +97,8 @@ class HookFetchClient {
 					requestConfig = this.interceptors.requestInterceptor(requestConfig)
 				} else {
 					// 默认注入 Token
-					const token = getAuthToken()
 					const headers = new Headers(requestConfig.headers || {})
-					if (token) {
-						headers.set('Authorization', token)
-					}
+
 					requestConfig.headers = headers
 				}
 

+ 7 - 7
packages/api-client/src/request.ts

@@ -99,16 +99,16 @@ class HttpClient {
 		const defaultInterceptor = (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
 			// const search = getParams(window.location.href)
 			// const enterpriseCode = search?.['enterpriseCode'] || 'a'
-			const token = localStorage.getItem('oauth2token')
+			// const token = localStorage.getItem('oauth2token')
 			// document.cookie.match(new RegExp('(^| )' + 'x-sessionId' + '=([^;]*)(;|$)'))?.[2]
 
 			// 添加token
-			if (token) {
-				if (!config.headers) {
-					config.headers = {} as InternalAxiosRequestConfig['headers']
-				}
-				config.headers.Authorization = token
-			}
+			// if (token) {
+			// 	if (!config.headers) {
+			// 		config.headers = {} as InternalAxiosRequestConfig['headers']
+			// 	}
+			// 	config.headers.Authorization = token
+			// }
 
 			return config
 		}