@hlw-zbzs/ai 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,175 @@
1
+ <template>
2
+ <div class="engine-section">
3
+ <div class="section-header">
4
+ <span class="section-title">选择大模型服务商</span>
5
+ <span class="section-hint">点击一键切换引擎</span>
6
+ </div>
7
+
8
+ <div class="engine-grid">
9
+ <div
10
+ v-for="item in list"
11
+ :key="item.type"
12
+ class="engine-card"
13
+ :class="{ active: engine === item.type }"
14
+ @click="onSelect(item)"
15
+ >
16
+ <div class="card-head">
17
+ <span class="card-name">{{ item.name }}</span>
18
+ <span v-if="item.free" class="free-badge">免密</span>
19
+ <span v-else-if="item.url" class="key-link" title="点击打开官网获取 Key" @click.stop="openPortal(item)">
20
+ <span>获取Key</span>
21
+ <n-icon :size="11"><OpenOutline /></n-icon>
22
+ </span>
23
+ </div>
24
+ <span class="card-desc">{{ item.tag }}</span>
25
+ </div>
26
+ </div>
27
+ </div>
28
+ </template>
29
+
30
+ <script setup lang="ts">
31
+ import { OpenOutline } from '@vicons/ionicons5'
32
+ import { NIcon } from 'naive-ui'
33
+ import { tools } from '@hlw-zbzs/core'
34
+ import { toast } from '@hlw-zbzs/vue'
35
+ import { useAi } from '../core'
36
+ import type { AiEngine, AiInfo } from '../types'
37
+
38
+ const emit = defineEmits<{
39
+ (event: 'change', type: AiEngine): void
40
+ }>()
41
+
42
+ const { engine, list, select } = useAi()
43
+
44
+ function openPortal(item: AiInfo) {
45
+ if (!item?.url) return
46
+ if (tools?.openExternal) {
47
+ tools.openExternal(item.url)
48
+ } else {
49
+ window.open(item.url, '_blank')
50
+ }
51
+ toast(`已为你打开【${item.name}】开发者控制台`, 'info')
52
+ }
53
+
54
+ function onSelect(item: AiInfo) {
55
+ select(item.type)
56
+ emit('change', item.type)
57
+ }
58
+ </script>
59
+
60
+ <style scoped lang="scss">
61
+ .engine-section {
62
+ display: flex;
63
+ flex-direction: column;
64
+ gap: 10px;
65
+ padding: 16px 16px 14px 16px;
66
+ border-bottom: 1px solid #f1f5f9;
67
+
68
+ .section-header {
69
+ display: flex;
70
+ align-items: center;
71
+ justify-content: space-between;
72
+ border-bottom: 1px solid #f1f5f9;
73
+ padding-bottom: 6px;
74
+
75
+ .section-title {
76
+ font-size: 14px;
77
+ font-weight: 400;
78
+ color: #1e293b;
79
+ }
80
+
81
+ .section-hint {
82
+ font-size: 12px;
83
+ font-weight: 400;
84
+ color: #94a3b8;
85
+ }
86
+ }
87
+
88
+ .engine-grid {
89
+ display: grid;
90
+ grid-template-columns: repeat(3, minmax(0, 1fr));
91
+ gap: 10px;
92
+
93
+ .engine-card {
94
+ background: #f8fafc;
95
+ border: 1px solid #e2e8f0;
96
+ border-radius: 6px;
97
+ padding: 9px 12px;
98
+ cursor: pointer;
99
+ user-select: none;
100
+ transition: all 0.15s ease;
101
+ display: flex;
102
+ flex-direction: column;
103
+ gap: 4px;
104
+ min-width: 0;
105
+
106
+ .card-head {
107
+ display: flex;
108
+ align-items: center;
109
+ justify-content: space-between;
110
+
111
+ .card-name {
112
+ font-size: 13px;
113
+ font-weight: 400;
114
+ color: #334155;
115
+ }
116
+
117
+ .free-badge {
118
+ font-size: 11px;
119
+ font-weight: 400;
120
+ color: #059669;
121
+ background: #ecfdf5;
122
+ padding: 0 4px;
123
+ border-radius: 3px;
124
+ }
125
+
126
+ .key-link {
127
+ display: inline-flex;
128
+ align-items: center;
129
+ gap: 2px;
130
+ font-size: 11px;
131
+ font-weight: 400;
132
+ color: #2563eb;
133
+ background: #eff6ff;
134
+ padding: 1px 5px;
135
+ border-radius: 3px;
136
+ cursor: pointer;
137
+ transition: all 0.15s ease;
138
+
139
+ &:hover {
140
+ background: #dbeafe;
141
+ color: #1d4ed8;
142
+ }
143
+ }
144
+ }
145
+
146
+ .card-desc {
147
+ font-size: 11px;
148
+ font-weight: 400;
149
+ color: #94a3b8;
150
+ white-space: nowrap;
151
+ overflow: hidden;
152
+ text-overflow: ellipsis;
153
+ }
154
+
155
+ &:hover {
156
+ border-color: #93c5fd;
157
+ background: #f1f5f9;
158
+ }
159
+
160
+ &.active {
161
+ border-color: #3b82f6;
162
+ background: #eff6ff;
163
+
164
+ .card-head .card-name {
165
+ color: #2563eb;
166
+ }
167
+
168
+ .card-desc {
169
+ color: #3b82f6;
170
+ }
171
+ }
172
+ }
173
+ }
174
+ }
175
+ </style>
@@ -0,0 +1,98 @@
1
+ <template>
2
+ <div class="ai-header">
3
+ <div class="header-title">
4
+ <n-icon :size="18" class="title-icon"><HardwareChipOutline /></n-icon>
5
+ <span class="title-text">大模型配置与测试</span>
6
+ </div>
7
+
8
+ <div class="header-right">
9
+ <n-tag
10
+ :type="ready ? 'success' : 'warning'"
11
+ size="small"
12
+ round
13
+ :bordered="false"
14
+ class="status-tag"
15
+ >
16
+ <template #icon>
17
+ <n-icon>
18
+ <CheckmarkCircleOutline v-if="ready" />
19
+ <AlertCircleOutline v-else />
20
+ </n-icon>
21
+ </template>
22
+ {{ ready ? `${info.name} 已就绪` : `${info.name} 未配 Key` }}
23
+ </n-tag>
24
+
25
+ <div class="switch-wrap">
26
+ <span class="switch-label">启用 AI:</span>
27
+ <n-switch :value="enabled" size="small" @update:value="onToggle" />
28
+ </div>
29
+ </div>
30
+ </div>
31
+ </template>
32
+
33
+ <script setup lang="ts">
34
+ import { HardwareChipOutline, CheckmarkCircleOutline, AlertCircleOutline } from '@vicons/ionicons5'
35
+ import { NIcon, NTag, NSwitch } from 'naive-ui'
36
+ import { toast } from '@hlw-zbzs/vue'
37
+ import { useAi } from '../core'
38
+
39
+ const { info, ready, enabled } = useAi()
40
+
41
+ function onToggle(val: boolean) {
42
+ enabled.value = val
43
+ toast(`AI 功能已${val ? '开启' : '停用'}`, val ? 'success' : 'info')
44
+ }
45
+ </script>
46
+
47
+ <style scoped lang="scss">
48
+ .ai-header {
49
+ height: 48px;
50
+ background: #ffffff;
51
+ border-bottom: 1px solid #e2e8f0;
52
+ display: flex;
53
+ align-items: center;
54
+ justify-content: space-between;
55
+ padding: 0 16px;
56
+ flex-shrink: 0;
57
+
58
+ .header-title {
59
+ display: flex;
60
+ align-items: center;
61
+ gap: 8px;
62
+
63
+ .title-icon {
64
+ color: #2563eb;
65
+ }
66
+
67
+ .title-text {
68
+ font-size: 15px;
69
+ font-weight: 400;
70
+ color: #0f172a;
71
+ letter-spacing: 0.5px;
72
+ }
73
+ }
74
+
75
+ .header-right {
76
+ display: flex;
77
+ align-items: center;
78
+ gap: 14px;
79
+
80
+ .status-tag {
81
+ user-select: none;
82
+ font-weight: 400;
83
+ }
84
+
85
+ .switch-wrap {
86
+ display: flex;
87
+ align-items: center;
88
+ gap: 6px;
89
+
90
+ .switch-label {
91
+ font-size: 13px;
92
+ font-weight: 400;
93
+ color: #475569;
94
+ }
95
+ }
96
+ }
97
+ }
98
+ </style>
@@ -0,0 +1,15 @@
1
+ import HlwAi from './ai.vue'
2
+ import AiHeader from './header.vue'
3
+ import AiGrid from './grid.vue'
4
+ import AiParams from './params.vue'
5
+ import AiChat from './chat.vue'
6
+
7
+ export {
8
+ HlwAi,
9
+ AiHeader,
10
+ AiGrid,
11
+ AiParams,
12
+ AiChat
13
+ }
14
+
15
+ export default HlwAi
@@ -0,0 +1,388 @@
1
+ <template>
2
+ <div class="params-container">
3
+ <div class="params-fields">
4
+ <!-- 凭证与连接参数 -->
5
+ <div class="field-section">
6
+ <div class="section-header">
7
+ <span class="section-title">API 凭证与模型</span>
8
+ </div>
9
+
10
+ <div class="form-row">
11
+ <div class="form-field flex-2">
12
+ <label class="field-label">
13
+ <span class="label-title">
14
+ <span>API Key</span>
15
+ <span v-if="!info.free" class="req-star">*</span>
16
+ <span v-else class="free-text">(本地模型无需填写)</span>
17
+ </span>
18
+ <a v-if="info.url" class="portal-link" title="在浏览器中打开开发者平台获取 Key" @click.stop="openPortal(info.url)">
19
+ <span>获取 {{ info.name }} 密钥</span>
20
+ <n-icon :size="12"><OpenOutline /></n-icon>
21
+ </a>
22
+ </label>
23
+ <n-input
24
+ :value="conf.key"
25
+ type="password"
26
+ show-password-on="click"
27
+ :placeholder="info.free ? '免密模型' : '请输入 API Key (sk-...)'"
28
+ size="medium"
29
+ clearable
30
+ @update:value="onKeyChange"
31
+ />
32
+ </div>
33
+
34
+ <div class="form-field flex-1">
35
+ <label class="field-label">模型 (Model)</label>
36
+ <n-select
37
+ :value="conf.model"
38
+ :options="modelOptions"
39
+ tag
40
+ filterable
41
+ size="medium"
42
+ placeholder="选择或输入模型"
43
+ @update:value="onModelChange"
44
+ />
45
+ </div>
46
+ </div>
47
+
48
+ <div class="form-field">
49
+ <label class="field-label">接口地址 (Base URL)</label>
50
+ <n-input
51
+ :value="conf.url"
52
+ type="text"
53
+ placeholder="https://api.openai.com/v1"
54
+ size="medium"
55
+ clearable
56
+ @update:value="onUrlChange"
57
+ />
58
+ </div>
59
+ </div>
60
+
61
+ <!-- 超参数调节 -->
62
+ <div class="field-section">
63
+ <div class="section-header">
64
+ <span class="section-title">生成参数</span>
65
+ </div>
66
+
67
+ <div class="form-row">
68
+ <div class="form-field flex-1">
69
+ <label class="field-label">随机温度 (Temperature): {{ conf.temp ?? 0.7 }}</label>
70
+ <div class="slider-box">
71
+ <n-slider
72
+ :value="conf.temp ?? 0.7"
73
+ :min="0"
74
+ :max="2"
75
+ :step="0.05"
76
+ style="flex: 1;"
77
+ @update:value="onTempChange"
78
+ />
79
+ <n-input-number
80
+ :value="conf.temp ?? 0.7"
81
+ :min="0"
82
+ :max="2"
83
+ :step="0.05"
84
+ size="small"
85
+ style="width: 76px;"
86
+ @update:value="onTempChange"
87
+ />
88
+ </div>
89
+ </div>
90
+
91
+ <div class="form-field flex-1">
92
+ <label class="field-label">最大 Token 数</label>
93
+ <n-input-number
94
+ :value="conf.tokens ?? 2048"
95
+ :min="128"
96
+ :max="16384"
97
+ :step="256"
98
+ size="medium"
99
+ style="width: 100%;"
100
+ @update:value="onTokensChange"
101
+ />
102
+ </div>
103
+ </div>
104
+
105
+ <div class="form-field">
106
+ <label class="field-label">系统人设 (System Prompt)</label>
107
+ <n-input
108
+ :value="conf.sys"
109
+ type="textarea"
110
+ placeholder="定义 AI 的角色定位与回答风格..."
111
+ :autosize="{ minRows: 2.5, maxRows: 4.5 }"
112
+ size="medium"
113
+ @update:value="onSysChange"
114
+ />
115
+ </div>
116
+ </div>
117
+ </div>
118
+
119
+ <!-- 左侧底部操作栏 -->
120
+ <div class="params-footer">
121
+ <div class="footer-actions">
122
+ <hlw-button
123
+ size="medium"
124
+ type="primary"
125
+ secondary
126
+ :loading="testing"
127
+ :icon="PulseOutline"
128
+ @click="onTest"
129
+ >
130
+ 测试连接
131
+ </hlw-button>
132
+
133
+ <hlw-button
134
+ size="medium"
135
+ secondary
136
+ :icon="RefreshOutline"
137
+ @click="onReset"
138
+ >
139
+ 恢复默认
140
+ </hlw-button>
141
+ </div>
142
+
143
+ <n-tag
144
+ v-if="testResult"
145
+ :type="testResult.success ? 'success' : 'error'"
146
+ size="small"
147
+ round
148
+ :bordered="false"
149
+ >
150
+ <template #icon>
151
+ <n-icon>
152
+ <CheckmarkCircleOutline v-if="testResult.success" />
153
+ <CloseCircleOutline v-else />
154
+ </n-icon>
155
+ </template>
156
+ {{ testResult.success ? `已连通 (${testResult.latency}ms)` : '连接失败' }}
157
+ </n-tag>
158
+ </div>
159
+ </div>
160
+ </template>
161
+
162
+ <script setup lang="ts">
163
+ import { ref, computed } from 'vue'
164
+ import {
165
+ PulseOutline,
166
+ RefreshOutline,
167
+ CheckmarkCircleOutline,
168
+ CloseCircleOutline,
169
+ OpenOutline
170
+ } from '@vicons/ionicons5'
171
+ import { NInput, NSelect, NSlider, NInputNumber, NTag, NIcon } from 'naive-ui'
172
+ import { tools } from '@hlw-zbzs/core'
173
+ import { toast, HlwButton } from '@hlw-zbzs/vue'
174
+ import { useAi } from '../core'
175
+ import type { AiTest } from '../types'
176
+
177
+ const {
178
+ engine,
179
+ conf,
180
+ info,
181
+ save,
182
+ reset,
183
+ test
184
+ } = useAi()
185
+
186
+ function openPortal(url?: string) {
187
+ if (!url) return
188
+ if (tools?.openExternal) {
189
+ tools.openExternal(url)
190
+ } else {
191
+ window.open(url, '_blank')
192
+ }
193
+ }
194
+
195
+ const testing = ref(false)
196
+ const testResult = ref<AiTest | null>(null)
197
+
198
+ const modelOptions = computed(() => {
199
+ const models = info.value.models || []
200
+ return models.map((item) => ({
201
+ label: item,
202
+ value: item
203
+ }))
204
+ })
205
+
206
+ function onKeyChange(val: string) {
207
+ save(engine.value, { key: val.trim() })
208
+ }
209
+
210
+ function onUrlChange(val: string) {
211
+ save(engine.value, { url: val.trim() })
212
+ }
213
+
214
+ function onModelChange(val: string) {
215
+ save(engine.value, { model: val.trim() })
216
+ }
217
+
218
+ function onTempChange(val: number | null) {
219
+ if (val !== null && val !== undefined) {
220
+ save(engine.value, { temp: val })
221
+ }
222
+ }
223
+
224
+ function onTokensChange(val: number | null) {
225
+ if (val) save(engine.value, { tokens: val })
226
+ }
227
+
228
+ function onSysChange(val: string) {
229
+ save(engine.value, { sys: val })
230
+ }
231
+
232
+ async function onTest() {
233
+ testing.value = true
234
+ testResult.value = null
235
+ try {
236
+ const res = await test()
237
+ testResult.value = res
238
+ if (res.success) {
239
+ toast(`连接成功,耗时 ${res.latency}ms`, 'success')
240
+ } else {
241
+ toast(`连接失败: ${res.msg}`, 'error')
242
+ }
243
+ } catch (err: any) {
244
+ testResult.value = {
245
+ success: false,
246
+ msg: err?.message || '连接异常',
247
+ latency: 0
248
+ }
249
+ toast(`连接异常: ${err?.message || '未知错误'}`, 'error')
250
+ } finally {
251
+ testing.value = false
252
+ }
253
+ }
254
+
255
+ function onReset() {
256
+ reset(engine.value)
257
+ testResult.value = null
258
+ toast(`已恢复【${info.value.name}】为默认配置`, 'info')
259
+ }
260
+
261
+ defineExpose({
262
+ clearTest: () => {
263
+ testResult.value = null
264
+ }
265
+ })
266
+ </script>
267
+
268
+ <style scoped lang="scss">
269
+ .params-container {
270
+ display: flex;
271
+ flex-direction: column;
272
+ gap: 16px;
273
+ padding: 16px 16px 0 16px;
274
+
275
+ .params-fields {
276
+ display: flex;
277
+ flex-direction: column;
278
+ gap: 16px;
279
+ }
280
+
281
+ .field-section {
282
+ display: flex;
283
+ flex-direction: column;
284
+ gap: 10px;
285
+
286
+ .section-header {
287
+ display: flex;
288
+ align-items: center;
289
+ justify-content: space-between;
290
+ border-bottom: 1px solid #f1f5f9;
291
+ padding-bottom: 6px;
292
+
293
+ .section-title {
294
+ font-size: 14px;
295
+ font-weight: 400;
296
+ color: #1e293b;
297
+ }
298
+ }
299
+
300
+ .form-row {
301
+ display: flex;
302
+ gap: 12px;
303
+
304
+ .flex-1 {
305
+ flex: 1;
306
+ min-width: 0;
307
+ }
308
+
309
+ .flex-2 {
310
+ flex: 2;
311
+ min-width: 0;
312
+ }
313
+ }
314
+
315
+ .form-field {
316
+ display: flex;
317
+ flex-direction: column;
318
+ gap: 6px;
319
+
320
+ .field-label {
321
+ font-size: 13px;
322
+ font-weight: 400;
323
+ color: #475569;
324
+ display: flex;
325
+ align-items: center;
326
+ justify-content: space-between;
327
+
328
+ .label-title {
329
+ display: flex;
330
+ align-items: center;
331
+ gap: 4px;
332
+ }
333
+
334
+ .req-star {
335
+ color: #ef4444;
336
+ }
337
+
338
+ .free-text {
339
+ color: #059669;
340
+ font-size: 12px;
341
+ }
342
+
343
+ .portal-link {
344
+ display: inline-flex;
345
+ align-items: center;
346
+ gap: 3px;
347
+ font-size: 12px;
348
+ font-weight: 400;
349
+ color: #2563eb;
350
+ cursor: pointer;
351
+ text-decoration: none;
352
+ transition: all 0.15s ease;
353
+
354
+ &:hover {
355
+ color: #1d4ed8;
356
+ text-decoration: underline;
357
+ }
358
+ }
359
+ }
360
+
361
+ .slider-box {
362
+ display: flex;
363
+ align-items: center;
364
+ gap: 10px;
365
+ }
366
+ }
367
+ }
368
+
369
+ .params-footer {
370
+ height: 54px;
371
+ margin-top: auto;
372
+ border-top: 1px solid #e2e8f0;
373
+ display: flex;
374
+ align-items: center;
375
+ justify-content: space-between;
376
+ padding: 0 16px;
377
+ margin-left: -16px;
378
+ margin-right: -16px;
379
+ background: #ffffff;
380
+
381
+ .footer-actions {
382
+ display: flex;
383
+ align-items: center;
384
+ gap: 8px;
385
+ }
386
+ }
387
+ }
388
+ </style>