Browse Source
- Updated agent configuration page to include a title generation model for guest users. - Added a new preferences page for users to set their preferred LLM model. - Implemented loading and saving functionality for user preferences. - Introduced new API endpoints for guest users to access models and system prompts. - Enhanced model loading logic to accommodate guest users and their default models. - Improved error handling and user feedback for model saving operations. - Added search tool executor with support for Bing, Baidu, and Google. - Implemented parsing logic for search results from different engines. - Updated session management to utilize user-specific model preferences.feat/ai-sdk-v6-upgrade
28 changed files with 1014 additions and 133 deletions
@ -0,0 +1,250 @@ |
|||
<script setup lang="ts"> |
|||
definePageMeta({ |
|||
layout: false, |
|||
}); |
|||
|
|||
interface ModelOption { |
|||
id: number; |
|||
name: string; |
|||
modelId: string; |
|||
providerName: string; |
|||
} |
|||
|
|||
const models = ref<ModelOption[]>([]); |
|||
const preferredModelId = ref<number | null>(null); |
|||
const loading = ref(false); |
|||
const saving = ref(false); |
|||
|
|||
async function loadModels() { |
|||
try { |
|||
const res = await $fetch<{ code: number; data: { list: any[] } }>("/api/llm/models"); |
|||
const list = res.data?.list ?? []; |
|||
models.value = list.map((m) => ({ |
|||
id: m.id, |
|||
name: m.name, |
|||
modelId: m.modelId, |
|||
providerName: m.providerName ?? "—", |
|||
})); |
|||
} catch { |
|||
models.value = []; |
|||
} |
|||
} |
|||
|
|||
async function loadUserConfig() { |
|||
try { |
|||
const res = await $fetch<{ code: number; data: { config: Record<string, unknown> } }>( |
|||
"/api/config/me", |
|||
); |
|||
const val = res.data?.config?.preferredLlmModelId; |
|||
preferredModelId.value = typeof val === "number" && val > 0 ? val : null; |
|||
} catch { |
|||
preferredModelId.value = null; |
|||
} |
|||
} |
|||
|
|||
async function handleSave() { |
|||
if (preferredModelId.value === null) { |
|||
const { $toast } = useNuxtApp(); |
|||
$toast?.error?.("请选择一个模型"); |
|||
return; |
|||
} |
|||
saving.value = true; |
|||
try { |
|||
await $fetch("/api/config/me", { |
|||
method: "PUT", |
|||
body: { key: "preferredLlmModelId", value: preferredModelId.value }, |
|||
}); |
|||
const { $toast } = useNuxtApp(); |
|||
$toast?.success?.("默认模型已保存"); |
|||
} catch { |
|||
const { $toast } = useNuxtApp(); |
|||
$toast?.error?.("保存失败,请稍后重试"); |
|||
} finally { |
|||
saving.value = false; |
|||
} |
|||
} |
|||
|
|||
onMounted(async () => { |
|||
loading.value = true; |
|||
await Promise.all([loadModels(), loadUserConfig()]); |
|||
loading.value = false; |
|||
}); |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="preferences-page"> |
|||
<header class="page-header"> |
|||
<h1 class="page-title">偏好设置</h1> |
|||
<p class="page-subtitle">管理您的个人偏好配置</p> |
|||
</header> |
|||
|
|||
<div v-if="loading" class="loading-state">加载中...</div> |
|||
|
|||
<div v-else class="settings-card"> |
|||
<div class="card-header"> |
|||
<h2 class="card-title">默认模型</h2> |
|||
<p class="card-desc">新建对话时将自动选中此模型</p> |
|||
</div> |
|||
|
|||
<div class="card-body"> |
|||
<div class="form-group"> |
|||
<label class="form-label">偏好模型</label> |
|||
<select |
|||
v-model="preferredModelId" |
|||
class="form-select" |
|||
> |
|||
<option :value="null" disabled>请选择模型</option> |
|||
<option |
|||
v-for="m in models" |
|||
:key="m.id" |
|||
:value="m.id" |
|||
> |
|||
{{ m.name }} ({{ m.providerName }}) |
|||
</option> |
|||
</select> |
|||
</div> |
|||
|
|||
<div class="form-actions"> |
|||
<button |
|||
type="button" |
|||
class="btn-primary" |
|||
:disabled="saving || preferredModelId === null" |
|||
@click="handleSave" |
|||
> |
|||
{{ saving ? "保存中..." : "保存" }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<style scoped> |
|||
.preferences-page { |
|||
padding: 40px; |
|||
max-width: 800px; |
|||
} |
|||
|
|||
.page-header { |
|||
margin-bottom: 32px; |
|||
} |
|||
|
|||
.page-title { |
|||
font-family: var(--font-display); |
|||
font-size: 32px; |
|||
font-weight: 400; |
|||
color: var(--color-ink); |
|||
letter-spacing: -0.3px; |
|||
margin: 0 0 8px 0; |
|||
} |
|||
|
|||
.page-subtitle { |
|||
font-size: 15px; |
|||
color: var(--color-muted); |
|||
margin: 0; |
|||
} |
|||
|
|||
.loading-state { |
|||
padding: 40px; |
|||
text-align: center; |
|||
color: var(--color-muted); |
|||
font-size: 14px; |
|||
} |
|||
|
|||
.settings-card { |
|||
background: var(--color-surface-card); |
|||
border-radius: 12px; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.card-header { |
|||
padding: 20px 24px; |
|||
border-bottom: 1px solid var(--color-hairline); |
|||
background: var(--color-surface-soft); |
|||
} |
|||
|
|||
.card-title { |
|||
font-size: 16px; |
|||
font-weight: 500; |
|||
color: var(--color-ink); |
|||
margin: 0 0 4px 0; |
|||
} |
|||
|
|||
.card-desc { |
|||
font-size: 13px; |
|||
color: var(--color-muted); |
|||
margin: 0; |
|||
} |
|||
|
|||
.card-body { |
|||
padding: 24px; |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 20px; |
|||
} |
|||
|
|||
.form-group { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 8px; |
|||
} |
|||
|
|||
.form-label { |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
color: var(--color-body-strong); |
|||
} |
|||
|
|||
.form-select { |
|||
width: 100%; |
|||
padding: 12px 16px; |
|||
font-size: 14px; |
|||
color: var(--color-ink); |
|||
background: var(--color-canvas); |
|||
border: 1px solid var(--color-hairline); |
|||
border-radius: 8px; |
|||
transition: all 0.15s ease; |
|||
appearance: none; |
|||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); |
|||
background-repeat: no-repeat; |
|||
background-position: right 12px center; |
|||
padding-right: 40px; |
|||
} |
|||
|
|||
.form-select:focus { |
|||
outline: none; |
|||
border-color: var(--color-primary); |
|||
box-shadow: 0 0 0 3px rgba(204, 120, 92, 0.15); |
|||
} |
|||
|
|||
.form-actions { |
|||
padding-top: 8px; |
|||
} |
|||
|
|||
.btn-primary { |
|||
padding: 12px 24px; |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
color: var(--color-on-primary); |
|||
background: var(--color-primary); |
|||
border: none; |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
transition: background 0.15s ease; |
|||
} |
|||
|
|||
.btn-primary:hover { |
|||
background: var(--color-primary-active); |
|||
} |
|||
|
|||
.btn-primary:disabled { |
|||
opacity: 0.6; |
|||
cursor: not-allowed; |
|||
} |
|||
|
|||
@media (max-width: 768px) { |
|||
.preferences-page { |
|||
padding: 24px; |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,18 @@ |
|||
export type MessagePartType = 'text' | 'reasoning' | 'tool-call' | 'tool-result' | 'tool-approval' |
|||
|
|||
export interface MessagePart { |
|||
id: string |
|||
type: MessagePartType |
|||
text?: string |
|||
toolName?: string |
|||
toolCallId?: string |
|||
args?: unknown |
|||
result?: unknown |
|||
state?: 'call' | 'result' | 'approval-requested' | 'approval-responded' |
|||
approvalId?: string |
|||
approved?: boolean |
|||
approvalReason?: string |
|||
isAutomaticApproval?: boolean |
|||
reasoningLoading?: boolean |
|||
reasoningDuration?: number |
|||
} |
|||
Binary file not shown.
@ -0,0 +1,33 @@ |
|||
import { defineWrappedResponseHandler } from "#server/utils/handler"; |
|||
import { R } from "#server/utils/response"; |
|||
import { getConfigGlobal } from "#server/utils/context"; |
|||
import { listSystemModels, getModelWithProviderByIdAny } from "#server/service/llm"; |
|||
|
|||
export default defineWrappedResponseHandler(async () => { |
|||
const systemModels = await listSystemModels(); |
|||
const defaultModelId = await getConfigGlobal("agentDefaultModelId"); |
|||
|
|||
const list = systemModels.map((m) => ({ |
|||
id: m.id, |
|||
name: m.name, |
|||
modelId: m.modelId, |
|||
providerName: "—", |
|||
})); |
|||
|
|||
if (defaultModelId) { |
|||
const exists = list.some((m) => m.id === defaultModelId); |
|||
if (!exists) { |
|||
const row = await getModelWithProviderByIdAny(defaultModelId); |
|||
if (row) { |
|||
list.unshift({ |
|||
id: row.model.id, |
|||
name: row.model.name, |
|||
modelId: row.model.modelId, |
|||
providerName: row.provider.name, |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
|
|||
return R.success({ list, defaultModelId: defaultModelId ?? null }); |
|||
}); |
|||
@ -0,0 +1,32 @@ |
|||
import { z } from "zod"; |
|||
|
|||
export const searchConfigSchema = z.object({ |
|||
engine: z |
|||
.enum(["bing", "baidu", "google"]) |
|||
.default("bing") |
|||
.describe("搜索引擎: bing(必应), baidu(百度), google(谷歌)"), |
|||
maxResults: z.number().int().positive().max(20).default(10), |
|||
timeout: z.number().int().positive().max(30000).default(10000), |
|||
language: z.string().min(1).default("zh-CN"), |
|||
region: z.string().min(1).default("CN"), |
|||
snippetLength: z.number().int().positive().max(500).default(200), |
|||
}); |
|||
|
|||
export type SearchToolConfig = z.infer<typeof searchConfigSchema>; |
|||
|
|||
export const DEFAULT_SEARCH_CONFIG: SearchToolConfig = { |
|||
engine: "bing", |
|||
maxResults: 10, |
|||
timeout: 10000, |
|||
language: "zh-CN", |
|||
region: "CN", |
|||
snippetLength: 200, |
|||
}; |
|||
|
|||
export function parseSearchConfig(raw: unknown): SearchToolConfig { |
|||
const parsed = searchConfigSchema.safeParse(raw); |
|||
if (!parsed.success) { |
|||
throw new Error(`Invalid search config: ${parsed.error.message}`); |
|||
} |
|||
return parsed.data; |
|||
} |
|||
@ -0,0 +1,119 @@ |
|||
export interface SearchResultItem { |
|||
title: string; |
|||
url: string; |
|||
snippet: string; |
|||
displayUrl?: string; |
|||
} |
|||
|
|||
export interface ParsedSearchResults { |
|||
items: SearchResultItem[]; |
|||
totalResults?: string; |
|||
engine: string; |
|||
} |
|||
|
|||
export function parseBingResults(html: string, maxResults: number, snippetLength: number): ParsedSearchResults { |
|||
const items: SearchResultItem[] = []; |
|||
|
|||
// Bing 结果项在 <li class="b_algo"> 中
|
|||
const itemRegex = /<li[^>]*class="[^"]*b_algo[^"]*"[^>]*>([\s\S]*?)<\/li>/gi; |
|||
let match: RegExpExecArray | null; |
|||
|
|||
while ((match = itemRegex.exec(html)) !== null && items.length < maxResults) { |
|||
const block = match[1]; |
|||
|
|||
// 标题和链接在 <h2><a href="..." ...>标题</a></h2>
|
|||
const linkMatch = block.match(/<a[^>]*href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/i); |
|||
if (!linkMatch) continue; |
|||
|
|||
const url = linkMatch[1]; |
|||
const title = stripTags(linkMatch[2]).trim(); |
|||
if (!title || !url) continue; |
|||
|
|||
// 摘要在 <p class="b_lineclamp..."> 或 <div class="b_caption"><p>
|
|||
const snippetMatch = |
|||
block.match(/<p[^>]*class="[^"]*b_lineclamp[^"]*"[^>]*>([\s\S]*?)<\/p>/i) || |
|||
block.match(/<div[^>]*class="[^"]*b_caption[^"]*"[^>]*>[\s\S]*?<p[^>]*>([\s\S]*?)<\/p>/i); |
|||
const snippet = snippetMatch ? truncate(stripTags(snippetMatch[1]).trim(), snippetLength) : ""; |
|||
|
|||
// 显示URL
|
|||
const displayMatch = block.match(/<cite[^>]*>([\s\S]*?)<\/cite>/i); |
|||
const displayUrl = displayMatch ? stripTags(displayMatch[1]).trim() : undefined; |
|||
|
|||
items.push({ title, url, snippet, displayUrl }); |
|||
} |
|||
|
|||
return { items, engine: "bing" }; |
|||
} |
|||
|
|||
export function parseBaiduResults(html: string, maxResults: number, snippetLength: number): ParsedSearchResults { |
|||
const items: SearchResultItem[] = []; |
|||
|
|||
// 百度结果项在 <div class="result ..."> 或 <div class="c-container ...">
|
|||
const itemRegex = /<div[^>]*class="[^"]*(?:result|c-container)[^"]*"[^>]*>([\s\S]*?)(?=<div[^>]*class="[^"]*(?:result|c-container)|$)/gi; |
|||
let match: RegExpExecArray | null; |
|||
|
|||
while ((match = itemRegex.exec(html)) !== null && items.length < maxResults) { |
|||
const block = match[1]; |
|||
|
|||
const linkMatch = block.match(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i); |
|||
if (!linkMatch) continue; |
|||
|
|||
const url = linkMatch[1]; |
|||
const title = stripTags(linkMatch[2]).trim(); |
|||
if (!title || !url) continue; |
|||
|
|||
const snippetMatch = block.match(/<span[^>]*class="[^"]*content-right[^"]*"[^>]*>([\s\S]*?)<\/span>/i) || |
|||
block.match(/<div[^>]*class="[^"]*c-abstract[^"]*"[^>]*>([\s\S]*?)<\/div>/i) || |
|||
block.match(/<span[^>]*>([\s\S]*?)<\/span>/i); |
|||
const snippet = snippetMatch ? truncate(stripTags(snippetMatch[1]).trim(), snippetLength) : ""; |
|||
|
|||
items.push({ title, url, snippet }); |
|||
} |
|||
|
|||
return { items, engine: "baidu" }; |
|||
} |
|||
|
|||
export function parseGoogleResults(html: string, maxResults: number, snippetLength: number): ParsedSearchResults { |
|||
const items: SearchResultItem[] = []; |
|||
|
|||
// Google 结果项在 <div class="g"> 中
|
|||
const itemRegex = /<div[^>]*class="[^"]*\bg\b[^"]*"[^>]*>([\s\S]*?)<\/div>\s*(?=<div[^>]*class="[^"]*\bg\b|<div[^>]*id="foot"|$)/gi; |
|||
let match: RegExpExecArray | null; |
|||
|
|||
while ((match = itemRegex.exec(html)) !== null && items.length < maxResults) { |
|||
const block = match[1]; |
|||
|
|||
const linkMatch = block.match(/<a[^>]*href="\/url\?q=(https?:\/\/[^&"]+)&[^"]*"[^>]*>([\s\S]*?)<\/a>/i) || |
|||
block.match(/<a[^>]*href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/i); |
|||
if (!linkMatch) continue; |
|||
|
|||
const url = decodeURIComponent(linkMatch[1]); |
|||
const title = stripTags(linkMatch[2]).trim(); |
|||
if (!title || !url) continue; |
|||
|
|||
const snippetMatch = block.match(/<span[^>]*>([\s\S]*?)<\/span>/i); |
|||
const snippet = snippetMatch ? truncate(stripTags(snippetMatch[1]).trim(), snippetLength) : ""; |
|||
|
|||
items.push({ title, url, snippet }); |
|||
} |
|||
|
|||
return { items, engine: "google" }; |
|||
} |
|||
|
|||
function stripTags(html: string): string { |
|||
return html |
|||
.replace(/<[^>]+>/g, "") |
|||
.replace(/&/g, "&") |
|||
.replace(/</g, "<") |
|||
.replace(/>/g, ">") |
|||
.replace(/"/g, '"') |
|||
.replace(/'/g, "'") |
|||
.replace(/ /g, " ") |
|||
.replace(/\s+/g, " ") |
|||
.trim(); |
|||
} |
|||
|
|||
function truncate(str: string, maxLen: number): string { |
|||
if (str.length <= maxLen) return str; |
|||
return str.slice(0, maxLen) + "..."; |
|||
} |
|||
@ -0,0 +1,173 @@ |
|||
import { z } from "zod"; |
|||
import type { JSONSchema7 } from "json-schema"; |
|||
import type { ToolExecutor, ToolContext, ToolResult } from "../../registry"; |
|||
import type { SearchToolConfig } from "./config"; |
|||
import { parseBingResults, parseBaiduResults, parseGoogleResults } from "./parse"; |
|||
|
|||
export const searchInputSchema = z.object({ |
|||
query: z.string().min(1).max(500).describe("搜索关键词"), |
|||
maxResults: z.number().int().positive().max(20).optional().describe("返回结果数量上限,不传则使用默认值"), |
|||
engine: z |
|||
.enum(["bing", "baidu", "google"]) |
|||
.optional() |
|||
.describe("搜索引擎,不传则使用配置默认值"), |
|||
}); |
|||
|
|||
type SearchInput = z.infer<typeof searchInputSchema>; |
|||
|
|||
function buildSearchUrl( |
|||
query: string, |
|||
engine: "bing" | "baidu" | "google", |
|||
language: string, |
|||
region: string, |
|||
): string { |
|||
const encoded = encodeURIComponent(query); |
|||
switch (engine) { |
|||
case "bing": |
|||
return `https://www.bing.com/search?q=${encoded}&setlang=${language}&cc=${region}&count=30`; |
|||
case "baidu": |
|||
return `https://www.baidu.com/s?wd=${encoded}&rn=30`; |
|||
case "google": |
|||
return `https://www.google.com/search?q=${encoded}&hl=${language}&gl=${region}&num=30`; |
|||
} |
|||
} |
|||
|
|||
function getBrowserHeaders(engine: "bing" | "baidu" | "google"): Record<string, string> { |
|||
return { |
|||
"User-Agent": |
|||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", |
|||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", |
|||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", |
|||
"Accept-Encoding": "gzip, deflate, br", |
|||
...(engine === "google" ? { "Cookie": "CONSENT=YES+cb.20240101-00-0;" } : {}), |
|||
}; |
|||
} |
|||
|
|||
export const searchExecutor: ToolExecutor<SearchToolConfig> = { |
|||
buildInputSchema(_config: SearchToolConfig): JSONSchema7 { |
|||
return { |
|||
type: "object", |
|||
properties: { |
|||
query: { type: "string", description: "搜索关键词" }, |
|||
maxResults: { type: "number", description: "返回结果数量上限" }, |
|||
engine: { |
|||
type: "string", |
|||
enum: ["bing", "baidu", "google"], |
|||
description: "搜索引擎", |
|||
}, |
|||
}, |
|||
required: ["query"], |
|||
}; |
|||
}, |
|||
|
|||
buildDescription(config: SearchToolConfig): string { |
|||
return `网络搜索工具。使用 ${config.engine} 搜索引擎获取实时网页结果。默认返回最多 ${config.maxResults} 条结果(标题+URL+摘要)。语言: ${config.language},地区: ${config.region}。`; |
|||
}, |
|||
|
|||
async execute( |
|||
input: unknown, |
|||
config: SearchToolConfig, |
|||
_ctx: ToolContext, |
|||
): Promise<ToolResult> { |
|||
const start = Date.now(); |
|||
|
|||
const parsed = searchInputSchema.safeParse(input); |
|||
if (!parsed.success) { |
|||
return { |
|||
success: false, |
|||
data: null, |
|||
error: `输入参数校验失败: ${parsed.error.message}`, |
|||
metadata: { durationMs: Date.now() - start }, |
|||
}; |
|||
} |
|||
|
|||
const inp = parsed.data as SearchInput; |
|||
const engine = inp.engine ?? config.engine; |
|||
const maxResults = inp.maxResults ?? config.maxResults; |
|||
const searchUrl = buildSearchUrl(inp.query, engine, config.language, config.region); |
|||
|
|||
const controller = new AbortController(); |
|||
const timeoutId = setTimeout(() => controller.abort(), config.timeout); |
|||
|
|||
try { |
|||
const response = await fetch(searchUrl, { |
|||
method: "GET", |
|||
headers: getBrowserHeaders(engine), |
|||
signal: controller.signal, |
|||
redirect: "follow", |
|||
}); |
|||
|
|||
clearTimeout(timeoutId); |
|||
|
|||
if (!response.ok) { |
|||
return { |
|||
success: false, |
|||
data: null, |
|||
error: `搜索引擎返回 HTTP ${response.status} ${response.statusText}`, |
|||
metadata: { durationMs: Date.now() - start }, |
|||
}; |
|||
} |
|||
|
|||
const html = await response.text(); |
|||
|
|||
let parsedResults; |
|||
switch (engine) { |
|||
case "bing": |
|||
parsedResults = parseBingResults(html, maxResults, config.snippetLength); |
|||
break; |
|||
case "baidu": |
|||
parsedResults = parseBaiduResults(html, maxResults, config.snippetLength); |
|||
break; |
|||
case "google": |
|||
parsedResults = parseGoogleResults(html, maxResults, config.snippetLength); |
|||
break; |
|||
} |
|||
|
|||
if (parsedResults.items.length === 0) { |
|||
return { |
|||
success: true, |
|||
data: { |
|||
query: inp.query, |
|||
engine, |
|||
items: [], |
|||
message: "未找到搜索结果,可能搜索引擎返回了反爬页面或验证码。建议更换关键词或引擎重试。", |
|||
}, |
|||
metadata: { |
|||
responseSize: html.length, |
|||
durationMs: Date.now() - start, |
|||
}, |
|||
}; |
|||
} |
|||
|
|||
return { |
|||
success: true, |
|||
data: { |
|||
query: inp.query, |
|||
engine, |
|||
totalResults: parsedResults.items.length, |
|||
items: parsedResults.items, |
|||
}, |
|||
metadata: { |
|||
responseSize: html.length, |
|||
durationMs: Date.now() - start, |
|||
}, |
|||
}; |
|||
} catch (e) { |
|||
clearTimeout(timeoutId); |
|||
if (e instanceof Error && e.name === "AbortError") { |
|||
return { |
|||
success: false, |
|||
data: null, |
|||
error: `搜索请求超时 (${config.timeout}ms)`, |
|||
metadata: { durationMs: Date.now() - start }, |
|||
}; |
|||
} |
|||
return { |
|||
success: false, |
|||
data: null, |
|||
error: e instanceof Error ? e.message : String(e), |
|||
metadata: { durationMs: Date.now() - start }, |
|||
}; |
|||
} |
|||
}, |
|||
}; |
|||
Loading…
Reference in new issue