feat(knowledge): add retry endpoint and 20-char text name limit
Add POST /api/tenant/knowledge/items/retry/:id to re-queue failed parse tasks, plus a 20-rune cap on text item names with matching frontend validation. Wires the retry button into the knowledge table with a colored status tag for clearer state feedback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -703,6 +703,9 @@ export const knowledgeApi = {
|
|||||||
removeItem(id: number) {
|
removeItem(id: number) {
|
||||||
return apiClient.remove<null>(`/api/tenant/knowledge/items/${id}`);
|
return apiClient.remove<null>(`/api/tenant/knowledge/items/${id}`);
|
||||||
},
|
},
|
||||||
|
retryItem(id: number) {
|
||||||
|
return apiClient.post<KnowledgeItem, null>(`/api/tenant/knowledge/items/retry/${id}`, null);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const imagesApi = {
|
export const imagesApi = {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const errorMessageMap: Record<string, string> = {
|
|||||||
keyword_exists: "关键词已存在",
|
keyword_exists: "关键词已存在",
|
||||||
keyword_limit_reached: "关键词数量已达当前套餐上限",
|
keyword_limit_reached: "关键词数量已达当前套餐上限",
|
||||||
question_limit_reached: "当前关键词下的问题数量已达上限",
|
question_limit_reached: "当前关键词下的问题数量已达上限",
|
||||||
|
knowledge_text_name_too_long: "文本名称不能超过 20 个字",
|
||||||
brand_not_found: "品牌不存在或已删除",
|
brand_not_found: "品牌不存在或已删除",
|
||||||
keyword_not_found: "关键词不存在或已删除",
|
keyword_not_found: "关键词不存在或已删除",
|
||||||
publish_cover_required: "当前选择的平台要求上传封面图,请先上传封面图",
|
publish_cover_required: "当前选择的平台要求上传封面图,请先上传封面图",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { DeleteOutlined, EyeOutlined, MoreOutlined, PlusOutlined, UploadOutlined } from "@ant-design/icons-vue";
|
import { DeleteOutlined, EyeOutlined, MoreOutlined, PlusOutlined, ReloadOutlined, UploadOutlined } from "@ant-design/icons-vue";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||||
import { message, Modal, type TableColumnsType, type UploadProps } from "ant-design-vue";
|
import { message, Modal, type TableColumnsType, type UploadProps } from "ant-design-vue";
|
||||||
import type { KnowledgeGroup, KnowledgeItem } from "@geo/shared-types";
|
import type { KnowledgeGroup, KnowledgeItem } from "@geo/shared-types";
|
||||||
@@ -13,6 +13,7 @@ import { formatError } from "@/lib/errors";
|
|||||||
type KnowledgeSourceType = "document" | "website" | "text";
|
type KnowledgeSourceType = "document" | "website" | "text";
|
||||||
type KnowledgeGroupLevel = "root" | "child";
|
type KnowledgeGroupLevel = "root" | "child";
|
||||||
const MAX_WEBSITE_URLS = 3;
|
const MAX_WEBSITE_URLS = 3;
|
||||||
|
const MAX_TEXT_NAME_LENGTH = 20;
|
||||||
|
|
||||||
interface TreeNode {
|
interface TreeNode {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -62,7 +63,7 @@ const columns: TableColumnsType<KnowledgeItem> = [
|
|||||||
{ title: "大小", dataIndex: "size_bytes", key: "size_bytes", width: 120 },
|
{ title: "大小", dataIndex: "size_bytes", key: "size_bytes", width: 120 },
|
||||||
{ title: "AI学习", dataIndex: "status", key: "status", width: 120 },
|
{ title: "AI学习", dataIndex: "status", key: "status", width: 120 },
|
||||||
{ title: "上传时间", dataIndex: "created_at", key: "created_at", width: 180 },
|
{ title: "上传时间", dataIndex: "created_at", key: "created_at", width: 180 },
|
||||||
{ title: "操作", key: "actions", width: 120, fixed: "right", align: "right" },
|
{ title: "操作", key: "actions", width: 160, fixed: "right", align: "right" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const rootGroups = computed(() => groupsQuery.data.value ?? []);
|
const rootGroups = computed(() => groupsQuery.data.value ?? []);
|
||||||
@@ -177,6 +178,10 @@ const itemMutation = useMutation({
|
|||||||
if (validTexts.length === 0) {
|
if (validTexts.length === 0) {
|
||||||
throw new Error("请至少输入一项完整的文本内容");
|
throw new Error("请至少输入一项完整的文本内容");
|
||||||
}
|
}
|
||||||
|
const oversizedText = validTexts.find((item) => [...item.name.trim()].length > MAX_TEXT_NAME_LENGTH);
|
||||||
|
if (oversizedText) {
|
||||||
|
throw new Error(`文本名称不能超过 ${MAX_TEXT_NAME_LENGTH} 个字`);
|
||||||
|
}
|
||||||
for (const txt of validTexts) {
|
for (const txt of validTexts) {
|
||||||
promises.push(
|
promises.push(
|
||||||
knowledgeApi.createTextItem({
|
knowledgeApi.createTextItem({
|
||||||
@@ -227,6 +232,15 @@ const deleteItemMutation = useMutation({
|
|||||||
onError: (error) => message.error(formatError(error)),
|
onError: (error) => message.error(formatError(error)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const retryItemMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => knowledgeApi.retryItem(id),
|
||||||
|
onSuccess: async () => {
|
||||||
|
message.success("已重新提交学习");
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["knowledge"] });
|
||||||
|
},
|
||||||
|
onError: (error) => message.error(formatError(error)),
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => groupsQuery.data.value,
|
() => groupsQuery.data.value,
|
||||||
(groups) => {
|
(groups) => {
|
||||||
@@ -486,6 +500,22 @@ function formatStatus(value: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusColor(value: string): string {
|
||||||
|
switch (value) {
|
||||||
|
case "completed":
|
||||||
|
return "success";
|
||||||
|
case "failed":
|
||||||
|
return "error";
|
||||||
|
case "processing":
|
||||||
|
case "pending":
|
||||||
|
return "processing";
|
||||||
|
case "deleted":
|
||||||
|
return "default";
|
||||||
|
default:
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatSize(value: number): string {
|
function formatSize(value: number): string {
|
||||||
if (value < 1024) return `${value} B`;
|
if (value < 1024) return `${value} B`;
|
||||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||||
@@ -582,15 +612,27 @@ function closeItemDetail(): void {
|
|||||||
{{ formatSize(record.size_bytes) }}
|
{{ formatSize(record.size_bytes) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'status'">
|
<template v-else-if="column.key === 'status'">
|
||||||
<span class="knowledge-status-text">
|
<a-tag :color="statusColor(record.status)" class="knowledge-status-tag">
|
||||||
{{ formatStatus(record.status) }}
|
{{ formatStatus(record.status) }}
|
||||||
</span>
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'created_at'">
|
<template v-else-if="column.key === 'created_at'">
|
||||||
{{ formatDateTime(record.created_at) }}
|
{{ formatDateTime(record.created_at) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'actions'">
|
<template v-else-if="column.key === 'actions'">
|
||||||
<div class="table-actions-row">
|
<div class="table-actions-row">
|
||||||
|
<a-tooltip v-if="record.status === 'failed'" title="重试">
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
size="small"
|
||||||
|
class="action-btn action-retry"
|
||||||
|
:loading="retryItemMutation.isPending.value && retryItemMutation.variables.value === record.id"
|
||||||
|
@click="retryItemMutation.mutate(record.id)"
|
||||||
|
>
|
||||||
|
<ReloadOutlined />
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
<a-tooltip title="查看">
|
<a-tooltip title="查看">
|
||||||
<a-button type="text" shape="circle" size="small" class="action-btn action-eye" @click="openItemDetail(record.id)">
|
<a-button type="text" shape="circle" size="small" class="action-btn action-eye" @click="openItemDetail(record.id)">
|
||||||
<EyeOutlined />
|
<EyeOutlined />
|
||||||
@@ -698,7 +740,13 @@ function closeItemDetail(): void {
|
|||||||
<div v-for="(txt, index) in textItems" :key="index" class="text-item-box">
|
<div v-for="(txt, index) in textItems" :key="index" class="text-item-box">
|
||||||
<div class="text-item-row">
|
<div class="text-item-row">
|
||||||
<span class="text-item-label">文本名称:</span>
|
<span class="text-item-label">文本名称:</span>
|
||||||
<a-input v-model:value="txt.name" placeholder="请输入文本名称" style="flex: 1;" />
|
<a-input
|
||||||
|
v-model:value="txt.name"
|
||||||
|
:maxlength="MAX_TEXT_NAME_LENGTH"
|
||||||
|
show-count
|
||||||
|
placeholder="请输入文本名称"
|
||||||
|
style="flex: 1;"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-item-row">
|
<div class="text-item-row">
|
||||||
<span class="text-item-label">文本内容:</span>
|
<span class="text-item-label">文本内容:</span>
|
||||||
@@ -793,7 +841,7 @@ function closeItemDetail(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.knowledge-sidebar__add {
|
.knowledge-sidebar__add {
|
||||||
color: #666;
|
color: #1677ff;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -802,7 +850,7 @@ function closeItemDetail(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.knowledge-sidebar__add:hover {
|
.knowledge-sidebar__add:hover {
|
||||||
color: #1677ff;
|
color: #4096ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-node-wrapper {
|
.tree-node-wrapper {
|
||||||
@@ -836,8 +884,31 @@ function closeItemDetail(): void {
|
|||||||
color: #1677ff;
|
color: #1677ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.knowledge-status-text {
|
:deep(.ant-tree) {
|
||||||
color: #6b7280;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-tree-treenode) {
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-tree-node-content-wrapper) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-tree-title) {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-status-tag {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Actions */
|
/* Actions */
|
||||||
@@ -852,6 +923,7 @@ function closeItemDetail(): void {
|
|||||||
color: #8c8c8c;
|
color: #8c8c8c;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
.action-retry:hover { color: #1677ff; background: #e6f4ff; }
|
||||||
.action-eye:hover { color: #13c2c2; background: #e6fffb; }
|
.action-eye:hover { color: #13c2c2; background: #e6fffb; }
|
||||||
.action-delete:hover { color: #ff4d4f; background: #fff2f0; }
|
.action-delete:hover { color: #ff4d4f; background: #fff2f0; }
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -32,6 +33,7 @@ const (
|
|||||||
defaultKnowledgeCleanupPoll = 15 * time.Second
|
defaultKnowledgeCleanupPoll = 15 * time.Second
|
||||||
defaultKnowledgeChildGroupName = "默认目录"
|
defaultKnowledgeChildGroupName = "默认目录"
|
||||||
maxKnowledgeSnippetsPerItem = 2
|
maxKnowledgeSnippetsPerItem = 2
|
||||||
|
maxKnowledgeTextNameRunes = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
type KnowledgeService struct {
|
type KnowledgeService struct {
|
||||||
@@ -598,9 +600,14 @@ func (s *KnowledgeService) CreateTextItem(ctx context.Context, req KnowledgeText
|
|||||||
return nil, response.ErrBadRequest(40051, "knowledge_content_required", "knowledge content is required")
|
return nil, response.ErrBadRequest(40051, "knowledge_content_required", "knowledge content is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if utf8.RuneCountInString(name) > maxKnowledgeTextNameRunes {
|
||||||
|
return nil, response.ErrBadRequest(40055, "knowledge_text_name_too_long", "knowledge text name must be at most 20 characters")
|
||||||
|
}
|
||||||
|
|
||||||
return s.queueKnowledgeItem(ctx, actor.TenantID, knowledgeIngestInput{
|
return s.queueKnowledgeItem(ctx, actor.TenantID, knowledgeIngestInput{
|
||||||
GroupID: req.GroupID,
|
GroupID: req.GroupID,
|
||||||
Name: strings.TrimSpace(req.Name),
|
Name: name,
|
||||||
SourceType: "text",
|
SourceType: "text",
|
||||||
ContentText: &content,
|
ContentText: &content,
|
||||||
SizeBytes: int64(len([]byte(content))),
|
SizeBytes: int64(len([]byte(content))),
|
||||||
@@ -715,6 +722,100 @@ func (s *KnowledgeService) DeleteItem(ctx context.Context, itemID int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *KnowledgeService) RetryItem(ctx context.Context, itemID int64) (*KnowledgeItemResponse, error) {
|
||||||
|
actor := auth.MustActor(ctx)
|
||||||
|
|
||||||
|
var sourceType string
|
||||||
|
var status string
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT source_type, status
|
||||||
|
FROM knowledge_items
|
||||||
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||||
|
`, itemID, actor.TenantID).Scan(&sourceType, &status)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, response.ErrNotFound(40450, "knowledge_item_not_found", "knowledge item not found")
|
||||||
|
}
|
||||||
|
return nil, response.ErrInternal(50051, "knowledge_items_query_failed", "failed to load knowledge item")
|
||||||
|
}
|
||||||
|
if status != "failed" {
|
||||||
|
return nil, response.ErrConflict(40952, "knowledge_item_retry_unavailable", "only failed knowledge items can be retried")
|
||||||
|
}
|
||||||
|
if err := s.validateIngestionDependencies(sourceType); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50055, "knowledge_item_retry_failed", "failed to begin retry transaction")
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
var parseTaskID int64
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
UPDATE knowledge_parse_tasks
|
||||||
|
SET status = 'pending',
|
||||||
|
error_message = NULL,
|
||||||
|
started_at = NULL,
|
||||||
|
completed_at = NULL,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = (
|
||||||
|
SELECT id
|
||||||
|
FROM knowledge_parse_tasks
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND knowledge_item_id = $2
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
`, actor.TenantID, itemID).Scan(&parseTaskID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, response.ErrConflict(40952, "knowledge_item_retry_unavailable", "knowledge parse task is unavailable")
|
||||||
|
}
|
||||||
|
return nil, response.ErrInternal(50055, "knowledge_item_retry_failed", "failed to reset knowledge parse task")
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := tx.Exec(ctx, `
|
||||||
|
UPDATE knowledge_items
|
||||||
|
SET status = 'pending',
|
||||||
|
error_message = NULL,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
AND tenant_id = $2
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND status = 'failed'
|
||||||
|
`, itemID, actor.TenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50055, "knowledge_item_retry_failed", "failed to reset knowledge item")
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return nil, response.ErrConflict(40952, "knowledge_item_retry_unavailable", "only failed knowledge items can be retried")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, response.ErrInternal(50055, "knowledge_item_retry_failed", "failed to commit retry transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.enqueueParseJob(knowledgeParseJob{
|
||||||
|
TenantID: actor.TenantID,
|
||||||
|
ItemID: itemID,
|
||||||
|
ParseTaskID: parseTaskID,
|
||||||
|
}); err != nil {
|
||||||
|
_ = s.markKnowledgeIngestionFailed(context.Background(), actor.TenantID, itemID, parseTaskID, err)
|
||||||
|
return nil, response.ErrServiceUnavailable(50356, "knowledge_queue_unavailable", "knowledge parse queue is busy, please retry")
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := s.listKnowledgeItemsByIDs(ctx, actor.TenantID, []int64{itemID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil, response.ErrNotFound(40450, "knowledge_item_not_found", "knowledge item not found")
|
||||||
|
}
|
||||||
|
return &items[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *KnowledgeService) ResolveContext(
|
func (s *KnowledgeService) ResolveContext(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tenantID int64,
|
tenantID int64,
|
||||||
|
|||||||
@@ -190,6 +190,21 @@ func (h *KnowledgeHandler) CreateFileItem(c *gin.Context) {
|
|||||||
response.SuccessWithStatus(c, http.StatusCreated, data)
|
response.SuccessWithStatus(c, http.StatusCreated, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *KnowledgeHandler) RetryItem(c *gin.Context) {
|
||||||
|
itemID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "knowledge item id must be a number"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.svc.RetryItem(c.Request.Context(), itemID)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, data)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *KnowledgeHandler) DeleteItem(c *gin.Context) {
|
func (h *KnowledgeHandler) DeleteItem(c *gin.Context) {
|
||||||
itemID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
itemID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ func RegisterRoutes(app *bootstrap.App) {
|
|||||||
knowledge.POST("/items/text", knowledgeHandler.CreateTextItem)
|
knowledge.POST("/items/text", knowledgeHandler.CreateTextItem)
|
||||||
knowledge.POST("/items/url", knowledgeHandler.CreateURLItem)
|
knowledge.POST("/items/url", knowledgeHandler.CreateURLItem)
|
||||||
knowledge.POST("/items/file", knowledgeHandler.CreateFileItem)
|
knowledge.POST("/items/file", knowledgeHandler.CreateFileItem)
|
||||||
|
knowledge.POST("/items/retry/:id", knowledgeHandler.RetryItem)
|
||||||
knowledge.DELETE("/items/:id", knowledgeHandler.DeleteItem)
|
knowledge.DELETE("/items/:id", knowledgeHandler.DeleteItem)
|
||||||
|
|
||||||
// Prompt Rules
|
// Prompt Rules
|
||||||
|
|||||||
Reference in New Issue
Block a user