Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a2f4813af | ||
|
|
02ef1a37fc | ||
|
|
3d3c0494ab | ||
|
|
e2de00eeef | ||
|
|
21a1e4ba82 | ||
|
|
3f7d76d4e2 | ||
|
|
b41aee2278 | ||
|
|
9a02cbe0d3 | ||
|
|
9b05116289 | ||
|
|
892cfbafde | ||
|
|
a6286dbffd | ||
|
|
e3a355aaad | ||
|
|
50c134a4bc | ||
|
|
4061c83b26 | ||
|
|
bd14023af3 | ||
|
|
55f80c568b | ||
|
|
bc451416d1 | ||
|
|
ef31649ce3 | ||
|
|
044e35fde4 | ||
|
|
bdd183811e | ||
|
|
f844e2d0f5 | ||
|
|
55c6511875 | ||
|
|
7cf81aa8d3 | ||
|
|
bddb6296a0 | ||
|
|
54e7386e29 | ||
|
|
299f8b10c2 | ||
|
|
47fde14cf3 | ||
|
|
b61061b831 | ||
|
|
7fe1457f7c | ||
|
|
62ac2dd811 | ||
|
|
393c530647 | ||
|
|
a8693dd2d9 | ||
|
|
76cf17c729 | ||
|
|
f5fdb6899a | ||
|
|
1d928ab340 | ||
|
|
6a0c439667 | ||
|
|
f3eaf5a13d | ||
|
|
1b8685d9a5 | ||
|
|
a55469a9cb | ||
|
|
e780345ef3 |
@@ -9,9 +9,6 @@
|
||||
# CODEOWNERS file
|
||||
/.github/CODEOWNERS @laipz8200 @crazywoola
|
||||
|
||||
# Agents
|
||||
/.agents/skills/ @hyoban
|
||||
|
||||
# Docs
|
||||
/docs/ @crazywoola
|
||||
|
||||
|
||||
+98
-105
@@ -1450,58 +1450,54 @@ def clear_orphaned_file_records(force: bool):
|
||||
all_ids_in_tables = []
|
||||
for ids_table in ids_tables:
|
||||
query = ""
|
||||
match ids_table["type"]:
|
||||
case "uuid":
|
||||
click.echo(
|
||||
click.style(
|
||||
f"- Listing file ids in column {ids_table['column']} in table {ids_table['table']}",
|
||||
fg="white",
|
||||
)
|
||||
if ids_table["type"] == "uuid":
|
||||
click.echo(
|
||||
click.style(
|
||||
f"- Listing file ids in column {ids_table['column']} in table {ids_table['table']}", fg="white"
|
||||
)
|
||||
c = ids_table["column"]
|
||||
query = f"SELECT {c} FROM {ids_table['table']} WHERE {c} IS NOT NULL"
|
||||
with db.engine.begin() as conn:
|
||||
rs = conn.execute(sa.text(query))
|
||||
for i in rs:
|
||||
all_ids_in_tables.append({"table": ids_table["table"], "id": str(i[0])})
|
||||
case "text":
|
||||
t = ids_table["table"]
|
||||
click.echo(
|
||||
click.style(
|
||||
f"- Listing file-id-like strings in column {ids_table['column']} in table {t}",
|
||||
fg="white",
|
||||
)
|
||||
)
|
||||
query = (
|
||||
f"SELECT {ids_table['column']} FROM {ids_table['table']} WHERE {ids_table['column']} IS NOT NULL"
|
||||
)
|
||||
with db.engine.begin() as conn:
|
||||
rs = conn.execute(sa.text(query))
|
||||
for i in rs:
|
||||
all_ids_in_tables.append({"table": ids_table["table"], "id": str(i[0])})
|
||||
elif ids_table["type"] == "text":
|
||||
click.echo(
|
||||
click.style(
|
||||
f"- Listing file-id-like strings in column {ids_table['column']} in table {ids_table['table']}",
|
||||
fg="white",
|
||||
)
|
||||
query = (
|
||||
f"SELECT regexp_matches({ids_table['column']}, '{guid_regexp}', 'g') AS extracted_id "
|
||||
f"FROM {ids_table['table']}"
|
||||
)
|
||||
query = (
|
||||
f"SELECT regexp_matches({ids_table['column']}, '{guid_regexp}', 'g') AS extracted_id "
|
||||
f"FROM {ids_table['table']}"
|
||||
)
|
||||
with db.engine.begin() as conn:
|
||||
rs = conn.execute(sa.text(query))
|
||||
for i in rs:
|
||||
for j in i[0]:
|
||||
all_ids_in_tables.append({"table": ids_table["table"], "id": j})
|
||||
elif ids_table["type"] == "json":
|
||||
click.echo(
|
||||
click.style(
|
||||
(
|
||||
f"- Listing file-id-like JSON string in column {ids_table['column']} "
|
||||
f"in table {ids_table['table']}"
|
||||
),
|
||||
fg="white",
|
||||
)
|
||||
with db.engine.begin() as conn:
|
||||
rs = conn.execute(sa.text(query))
|
||||
for i in rs:
|
||||
for j in i[0]:
|
||||
all_ids_in_tables.append({"table": ids_table["table"], "id": j})
|
||||
case "json":
|
||||
click.echo(
|
||||
click.style(
|
||||
(
|
||||
f"- Listing file-id-like JSON string in column {ids_table['column']} "
|
||||
f"in table {ids_table['table']}"
|
||||
),
|
||||
fg="white",
|
||||
)
|
||||
)
|
||||
query = (
|
||||
f"SELECT regexp_matches({ids_table['column']}::text, '{guid_regexp}', 'g') AS extracted_id "
|
||||
f"FROM {ids_table['table']}"
|
||||
)
|
||||
with db.engine.begin() as conn:
|
||||
rs = conn.execute(sa.text(query))
|
||||
for i in rs:
|
||||
for j in i[0]:
|
||||
all_ids_in_tables.append({"table": ids_table["table"], "id": j})
|
||||
case _:
|
||||
pass
|
||||
)
|
||||
query = (
|
||||
f"SELECT regexp_matches({ids_table['column']}::text, '{guid_regexp}', 'g') AS extracted_id "
|
||||
f"FROM {ids_table['table']}"
|
||||
)
|
||||
with db.engine.begin() as conn:
|
||||
rs = conn.execute(sa.text(query))
|
||||
for i in rs:
|
||||
for j in i[0]:
|
||||
all_ids_in_tables.append({"table": ids_table["table"], "id": j})
|
||||
click.echo(click.style(f"Found {len(all_ids_in_tables)} file ids in tables.", fg="white"))
|
||||
|
||||
except Exception as e:
|
||||
@@ -1741,18 +1737,59 @@ def file_usage(
|
||||
if src_filter != src:
|
||||
continue
|
||||
|
||||
match ids_table["type"]:
|
||||
case "uuid":
|
||||
# Direct UUID match
|
||||
query = (
|
||||
f"SELECT {ids_table['pk_column']}, {ids_table['column']} "
|
||||
f"FROM {ids_table['table']} WHERE {ids_table['column']} IS NOT NULL"
|
||||
)
|
||||
with db.engine.begin() as conn:
|
||||
rs = conn.execute(sa.text(query))
|
||||
for row in rs:
|
||||
record_id = str(row[0])
|
||||
ref_file_id = str(row[1])
|
||||
if ids_table["type"] == "uuid":
|
||||
# Direct UUID match
|
||||
query = (
|
||||
f"SELECT {ids_table['pk_column']}, {ids_table['column']} "
|
||||
f"FROM {ids_table['table']} WHERE {ids_table['column']} IS NOT NULL"
|
||||
)
|
||||
with db.engine.begin() as conn:
|
||||
rs = conn.execute(sa.text(query))
|
||||
for row in rs:
|
||||
record_id = str(row[0])
|
||||
ref_file_id = str(row[1])
|
||||
if ref_file_id not in file_key_map:
|
||||
continue
|
||||
storage_key = file_key_map[ref_file_id]
|
||||
|
||||
# Apply filters
|
||||
if file_id and ref_file_id != file_id:
|
||||
continue
|
||||
if key and not storage_key.endswith(key):
|
||||
continue
|
||||
|
||||
# Only collect items within the requested page range
|
||||
if offset <= total_count < offset + limit:
|
||||
paginated_usages.append(
|
||||
{
|
||||
"src": f"{ids_table['table']}.{ids_table['column']}",
|
||||
"record_id": record_id,
|
||||
"file_id": ref_file_id,
|
||||
"key": storage_key,
|
||||
}
|
||||
)
|
||||
total_count += 1
|
||||
|
||||
elif ids_table["type"] in ("text", "json"):
|
||||
# Extract UUIDs from text/json content
|
||||
column_cast = f"{ids_table['column']}::text" if ids_table["type"] == "json" else ids_table["column"]
|
||||
query = (
|
||||
f"SELECT {ids_table['pk_column']}, {column_cast} "
|
||||
f"FROM {ids_table['table']} WHERE {ids_table['column']} IS NOT NULL"
|
||||
)
|
||||
with db.engine.begin() as conn:
|
||||
rs = conn.execute(sa.text(query))
|
||||
for row in rs:
|
||||
record_id = str(row[0])
|
||||
content = str(row[1])
|
||||
|
||||
# Find all UUIDs in the content
|
||||
import re
|
||||
|
||||
uuid_pattern = re.compile(guid_regexp, re.IGNORECASE)
|
||||
matches = uuid_pattern.findall(content)
|
||||
|
||||
for ref_file_id in matches:
|
||||
if ref_file_id not in file_key_map:
|
||||
continue
|
||||
storage_key = file_key_map[ref_file_id]
|
||||
@@ -1775,50 +1812,6 @@ def file_usage(
|
||||
)
|
||||
total_count += 1
|
||||
|
||||
case "text" | "json":
|
||||
# Extract UUIDs from text/json content
|
||||
column_cast = f"{ids_table['column']}::text" if ids_table["type"] == "json" else ids_table["column"]
|
||||
query = (
|
||||
f"SELECT {ids_table['pk_column']}, {column_cast} "
|
||||
f"FROM {ids_table['table']} WHERE {ids_table['column']} IS NOT NULL"
|
||||
)
|
||||
with db.engine.begin() as conn:
|
||||
rs = conn.execute(sa.text(query))
|
||||
for row in rs:
|
||||
record_id = str(row[0])
|
||||
content = str(row[1])
|
||||
|
||||
# Find all UUIDs in the content
|
||||
import re
|
||||
|
||||
uuid_pattern = re.compile(guid_regexp, re.IGNORECASE)
|
||||
matches = uuid_pattern.findall(content)
|
||||
|
||||
for ref_file_id in matches:
|
||||
if ref_file_id not in file_key_map:
|
||||
continue
|
||||
storage_key = file_key_map[ref_file_id]
|
||||
|
||||
# Apply filters
|
||||
if file_id and ref_file_id != file_id:
|
||||
continue
|
||||
if key and not storage_key.endswith(key):
|
||||
continue
|
||||
|
||||
# Only collect items within the requested page range
|
||||
if offset <= total_count < offset + limit:
|
||||
paginated_usages.append(
|
||||
{
|
||||
"src": f"{ids_table['table']}.{ids_table['column']}",
|
||||
"record_id": record_id,
|
||||
"file_id": ref_file_id,
|
||||
"key": storage_key,
|
||||
}
|
||||
)
|
||||
total_count += 1
|
||||
case _:
|
||||
pass
|
||||
|
||||
# Output results
|
||||
if output_json:
|
||||
result = {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from typing import Any, Literal
|
||||
|
||||
from flask import abort, make_response, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, TypeAdapter, field_validator
|
||||
from flask_restx import Resource, fields, marshal, marshal_with
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from controllers.common.errors import NoFileUploadedError, TooManyFilesError
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
@@ -17,11 +16,9 @@ from controllers.console.wraps import (
|
||||
)
|
||||
from extensions.ext_redis import redis_client
|
||||
from fields.annotation_fields import (
|
||||
Annotation,
|
||||
AnnotationExportList,
|
||||
AnnotationHitHistory,
|
||||
AnnotationHitHistoryList,
|
||||
AnnotationList,
|
||||
annotation_fields,
|
||||
annotation_hit_history_fields,
|
||||
build_annotation_model,
|
||||
)
|
||||
from libs.helper import uuid_value
|
||||
from libs.login import login_required
|
||||
@@ -92,14 +89,6 @@ reg(CreateAnnotationPayload)
|
||||
reg(UpdateAnnotationPayload)
|
||||
reg(AnnotationReplyStatusQuery)
|
||||
reg(AnnotationFilePayload)
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
Annotation,
|
||||
AnnotationList,
|
||||
AnnotationExportList,
|
||||
AnnotationHitHistory,
|
||||
AnnotationHitHistoryList,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/annotation-reply/<string:action>")
|
||||
@@ -118,11 +107,10 @@ class AnnotationReplyActionApi(Resource):
|
||||
def post(self, app_id, action: Literal["enable", "disable"]):
|
||||
app_id = str(app_id)
|
||||
args = AnnotationReplyPayload.model_validate(console_ns.payload)
|
||||
match action:
|
||||
case "enable":
|
||||
result = AppAnnotationService.enable_app_annotation(args.model_dump(), app_id)
|
||||
case "disable":
|
||||
result = AppAnnotationService.disable_app_annotation(app_id)
|
||||
if action == "enable":
|
||||
result = AppAnnotationService.enable_app_annotation(args.model_dump(), app_id)
|
||||
elif action == "disable":
|
||||
result = AppAnnotationService.disable_app_annotation(app_id)
|
||||
return result, 200
|
||||
|
||||
|
||||
@@ -213,33 +201,33 @@ class AnnotationApi(Resource):
|
||||
|
||||
app_id = str(app_id)
|
||||
annotation_list, total = AppAnnotationService.get_annotation_list_by_app_id(app_id, page, limit, keyword)
|
||||
annotation_models = TypeAdapter(list[Annotation]).validate_python(annotation_list, from_attributes=True)
|
||||
response = AnnotationList(
|
||||
data=annotation_models,
|
||||
has_more=len(annotation_list) == limit,
|
||||
limit=limit,
|
||||
total=total,
|
||||
page=page,
|
||||
)
|
||||
return response.model_dump(mode="json"), 200
|
||||
response = {
|
||||
"data": marshal(annotation_list, annotation_fields),
|
||||
"has_more": len(annotation_list) == limit,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"page": page,
|
||||
}
|
||||
return response, 200
|
||||
|
||||
@console_ns.doc("create_annotation")
|
||||
@console_ns.doc(description="Create a new annotation for an app")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.expect(console_ns.models[CreateAnnotationPayload.__name__])
|
||||
@console_ns.response(201, "Annotation created successfully", console_ns.models[Annotation.__name__])
|
||||
@console_ns.response(201, "Annotation created successfully", build_annotation_model(console_ns))
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_resource_check("annotation")
|
||||
@marshal_with(annotation_fields)
|
||||
@edit_permission_required
|
||||
def post(self, app_id):
|
||||
app_id = str(app_id)
|
||||
args = CreateAnnotationPayload.model_validate(console_ns.payload)
|
||||
data = args.model_dump(exclude_none=True)
|
||||
annotation = AppAnnotationService.up_insert_app_annotation_from_message(data, app_id)
|
||||
return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json")
|
||||
return annotation
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@@ -276,7 +264,7 @@ class AnnotationExportApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Annotations exported successfully",
|
||||
console_ns.models[AnnotationExportList.__name__],
|
||||
console_ns.model("AnnotationList", {"data": fields.List(fields.Nested(build_annotation_model(console_ns)))}),
|
||||
)
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@@ -286,8 +274,7 @@ class AnnotationExportApi(Resource):
|
||||
def get(self, app_id):
|
||||
app_id = str(app_id)
|
||||
annotation_list = AppAnnotationService.export_annotation_list_by_app_id(app_id)
|
||||
annotation_models = TypeAdapter(list[Annotation]).validate_python(annotation_list, from_attributes=True)
|
||||
response_data = AnnotationExportList(data=annotation_models).model_dump(mode="json")
|
||||
response_data = {"data": marshal(annotation_list, annotation_fields)}
|
||||
|
||||
# Create response with secure headers for CSV export
|
||||
response = make_response(response_data, 200)
|
||||
@@ -302,7 +289,7 @@ class AnnotationUpdateDeleteApi(Resource):
|
||||
@console_ns.doc("update_delete_annotation")
|
||||
@console_ns.doc(description="Update or delete an annotation")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "annotation_id": "Annotation ID"})
|
||||
@console_ns.response(200, "Annotation updated successfully", console_ns.models[Annotation.__name__])
|
||||
@console_ns.response(200, "Annotation updated successfully", build_annotation_model(console_ns))
|
||||
@console_ns.response(204, "Annotation deleted successfully")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@console_ns.expect(console_ns.models[UpdateAnnotationPayload.__name__])
|
||||
@@ -311,6 +298,7 @@ class AnnotationUpdateDeleteApi(Resource):
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_resource_check("annotation")
|
||||
@edit_permission_required
|
||||
@marshal_with(annotation_fields)
|
||||
def post(self, app_id, annotation_id):
|
||||
app_id = str(app_id)
|
||||
annotation_id = str(annotation_id)
|
||||
@@ -318,7 +306,7 @@ class AnnotationUpdateDeleteApi(Resource):
|
||||
annotation = AppAnnotationService.update_app_annotation_directly(
|
||||
args.model_dump(exclude_none=True), app_id, annotation_id
|
||||
)
|
||||
return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json")
|
||||
return annotation
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@@ -426,7 +414,14 @@ class AnnotationHitHistoryListApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Hit histories retrieved successfully",
|
||||
console_ns.models[AnnotationHitHistoryList.__name__],
|
||||
console_ns.model(
|
||||
"AnnotationHitHistoryList",
|
||||
{
|
||||
"data": fields.List(
|
||||
fields.Nested(console_ns.model("AnnotationHitHistoryItem", annotation_hit_history_fields))
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@@ -441,14 +436,11 @@ class AnnotationHitHistoryListApi(Resource):
|
||||
annotation_hit_history_list, total = AppAnnotationService.get_annotation_hit_histories(
|
||||
app_id, annotation_id, page, limit
|
||||
)
|
||||
history_models = TypeAdapter(list[AnnotationHitHistory]).validate_python(
|
||||
annotation_hit_history_list, from_attributes=True
|
||||
)
|
||||
response = AnnotationHitHistoryList(
|
||||
data=history_models,
|
||||
has_more=len(annotation_hit_history_list) == limit,
|
||||
limit=limit,
|
||||
total=total,
|
||||
page=page,
|
||||
)
|
||||
return response.model_dump(mode="json")
|
||||
response = {
|
||||
"data": marshal(annotation_hit_history_list, annotation_hit_history_fields),
|
||||
"has_more": len(annotation_hit_history_list) == limit,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"page": page,
|
||||
}
|
||||
return response
|
||||
|
||||
@@ -6,7 +6,6 @@ from pydantic import BaseModel, Field
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
|
||||
import services
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import (
|
||||
AppUnavailableError,
|
||||
@@ -34,6 +33,7 @@ from services.errors.audio import (
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
||||
|
||||
|
||||
class TextToSpeechPayload(BaseModel):
|
||||
@@ -47,11 +47,13 @@ class TextToSpeechVoiceQuery(BaseModel):
|
||||
language: str = Field(..., description="Language code")
|
||||
|
||||
|
||||
class AudioTranscriptResponse(BaseModel):
|
||||
text: str = Field(description="Transcribed text from audio")
|
||||
|
||||
|
||||
register_schema_models(console_ns, AudioTranscriptResponse, TextToSpeechPayload, TextToSpeechVoiceQuery)
|
||||
console_ns.schema_model(
|
||||
TextToSpeechPayload.__name__, TextToSpeechPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
|
||||
)
|
||||
console_ns.schema_model(
|
||||
TextToSpeechVoiceQuery.__name__,
|
||||
TextToSpeechVoiceQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/audio-to-text")
|
||||
@@ -62,7 +64,7 @@ class ChatMessageAudioApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Audio transcription successful",
|
||||
console_ns.models[AudioTranscriptResponse.__name__],
|
||||
console_ns.model("AudioTranscriptResponse", {"text": fields.String(description="Transcribed text from audio")}),
|
||||
)
|
||||
@console_ns.response(400, "Bad request - No audio uploaded or unsupported type")
|
||||
@console_ns.response(413, "Audio file too large")
|
||||
|
||||
@@ -508,19 +508,16 @@ class ChatConversationApi(Resource):
|
||||
case "created_at" | "-created_at" | _:
|
||||
query = query.where(Conversation.created_at <= end_datetime_utc)
|
||||
|
||||
match args.annotation_status:
|
||||
case "annotated":
|
||||
query = query.options(joinedload(Conversation.message_annotations)).join( # type: ignore
|
||||
MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id
|
||||
)
|
||||
case "not_annotated":
|
||||
query = (
|
||||
query.outerjoin(MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id)
|
||||
.group_by(Conversation.id)
|
||||
.having(func.count(MessageAnnotation.id) == 0)
|
||||
)
|
||||
case "all":
|
||||
pass
|
||||
if args.annotation_status == "annotated":
|
||||
query = query.options(joinedload(Conversation.message_annotations)).join( # type: ignore
|
||||
MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id
|
||||
)
|
||||
elif args.annotation_status == "not_annotated":
|
||||
query = (
|
||||
query.outerjoin(MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id)
|
||||
.group_by(Conversation.id)
|
||||
.having(func.count(MessageAnnotation.id) == 0)
|
||||
)
|
||||
|
||||
if app_model.mode == AppMode.ADVANCED_CHAT:
|
||||
query = query.where(Conversation.invoke_from != InvokeFrom.DEBUGGER)
|
||||
|
||||
@@ -7,7 +7,6 @@ from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import exists, select
|
||||
from werkzeug.exceptions import InternalServerError, NotFound
|
||||
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import (
|
||||
CompletionRequestError,
|
||||
@@ -36,6 +35,7 @@ from services.errors.message import MessageNotExistsError, SuggestedQuestionsAft
|
||||
from services.message_service import MessageService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
||||
|
||||
|
||||
class ChatMessagesQuery(BaseModel):
|
||||
@@ -90,22 +90,13 @@ class FeedbackExportQuery(BaseModel):
|
||||
raise ValueError("has_comment must be a boolean value")
|
||||
|
||||
|
||||
class AnnotationCountResponse(BaseModel):
|
||||
count: int = Field(description="Number of annotations")
|
||||
def reg(cls: type[BaseModel]):
|
||||
console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0))
|
||||
|
||||
|
||||
class SuggestedQuestionsResponse(BaseModel):
|
||||
data: list[str] = Field(description="Suggested question")
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
ChatMessagesQuery,
|
||||
MessageFeedbackPayload,
|
||||
FeedbackExportQuery,
|
||||
AnnotationCountResponse,
|
||||
SuggestedQuestionsResponse,
|
||||
)
|
||||
reg(ChatMessagesQuery)
|
||||
reg(MessageFeedbackPayload)
|
||||
reg(FeedbackExportQuery)
|
||||
|
||||
# Register models for flask_restx to avoid dict type issues in Swagger
|
||||
# Register in dependency order: base models first, then dependent models
|
||||
@@ -240,7 +231,7 @@ class ChatMessageListApi(Resource):
|
||||
@marshal_with(message_infinite_scroll_pagination_model)
|
||||
@edit_permission_required
|
||||
def get(self, app_model):
|
||||
args = ChatMessagesQuery.model_validate(request.args.to_dict())
|
||||
args = ChatMessagesQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
||||
|
||||
conversation = (
|
||||
db.session.query(Conversation)
|
||||
@@ -365,7 +356,7 @@ class MessageAnnotationCountApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Annotation count retrieved successfully",
|
||||
console_ns.models[AnnotationCountResponse.__name__],
|
||||
console_ns.model("AnnotationCountResponse", {"count": fields.Integer(description="Number of annotations")}),
|
||||
)
|
||||
@get_app_model
|
||||
@setup_required
|
||||
@@ -385,7 +376,9 @@ class MessageSuggestedQuestionApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Suggested questions retrieved successfully",
|
||||
console_ns.models[SuggestedQuestionsResponse.__name__],
|
||||
console_ns.model(
|
||||
"SuggestedQuestionsResponse", {"data": fields.List(fields.String(description="Suggested question"))}
|
||||
),
|
||||
)
|
||||
@console_ns.response(404, "Message or conversation not found")
|
||||
@setup_required
|
||||
@@ -435,7 +428,7 @@ class MessageFeedbackExportApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, app_model):
|
||||
args = FeedbackExportQuery.model_validate(request.args.to_dict())
|
||||
args = FeedbackExportQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
||||
|
||||
# Import the service function
|
||||
from services.feedback_service import FeedbackService
|
||||
|
||||
@@ -2,11 +2,9 @@ import logging
|
||||
|
||||
import httpx
|
||||
from flask import current_app, redirect, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from flask_restx import Resource, fields
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.schema import register_schema_models
|
||||
from libs.login import login_required
|
||||
from libs.oauth_data_source import NotionOAuth
|
||||
|
||||
@@ -16,26 +14,6 @@ from ..wraps import account_initialization_required, is_admin_or_owner_required,
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OAuthDataSourceResponse(BaseModel):
|
||||
data: str = Field(description="Authorization URL or 'internal' for internal setup")
|
||||
|
||||
|
||||
class OAuthDataSourceBindingResponse(BaseModel):
|
||||
result: str = Field(description="Operation result")
|
||||
|
||||
|
||||
class OAuthDataSourceSyncResponse(BaseModel):
|
||||
result: str = Field(description="Operation result")
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
OAuthDataSourceResponse,
|
||||
OAuthDataSourceBindingResponse,
|
||||
OAuthDataSourceSyncResponse,
|
||||
)
|
||||
|
||||
|
||||
def get_oauth_providers():
|
||||
with current_app.app_context():
|
||||
notion_oauth = NotionOAuth(
|
||||
@@ -56,7 +34,10 @@ class OAuthDataSource(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Authorization URL or internal setup success",
|
||||
console_ns.models[OAuthDataSourceResponse.__name__],
|
||||
console_ns.model(
|
||||
"OAuthDataSourceResponse",
|
||||
{"data": fields.Raw(description="Authorization URL or 'internal' for internal setup")},
|
||||
),
|
||||
)
|
||||
@console_ns.response(400, "Invalid provider")
|
||||
@console_ns.response(403, "Admin privileges required")
|
||||
@@ -120,7 +101,7 @@ class OAuthDataSourceBinding(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Data source binding success",
|
||||
console_ns.models[OAuthDataSourceBindingResponse.__name__],
|
||||
console_ns.model("OAuthDataSourceBindingResponse", {"result": fields.String(description="Operation result")}),
|
||||
)
|
||||
@console_ns.response(400, "Invalid provider or code")
|
||||
def get(self, provider: str):
|
||||
@@ -152,7 +133,7 @@ class OAuthDataSourceSync(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Data source sync success",
|
||||
console_ns.models[OAuthDataSourceSyncResponse.__name__],
|
||||
console_ns.model("OAuthDataSourceSyncResponse", {"result": fields.String(description="Operation result")}),
|
||||
)
|
||||
@console_ns.response(400, "Invalid provider or sync failed")
|
||||
@setup_required
|
||||
|
||||
@@ -2,11 +2,10 @@ import base64
|
||||
import secrets
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from flask_restx import Resource, fields
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.auth.error import (
|
||||
EmailCodeError,
|
||||
@@ -49,31 +48,8 @@ class ForgotPasswordResetPayload(BaseModel):
|
||||
return valid_password(value)
|
||||
|
||||
|
||||
class ForgotPasswordEmailResponse(BaseModel):
|
||||
result: str = Field(description="Operation result")
|
||||
data: str | None = Field(default=None, description="Reset token")
|
||||
code: str | None = Field(default=None, description="Error code if account not found")
|
||||
|
||||
|
||||
class ForgotPasswordCheckResponse(BaseModel):
|
||||
is_valid: bool = Field(description="Whether code is valid")
|
||||
email: EmailStr = Field(description="Email address")
|
||||
token: str = Field(description="New reset token")
|
||||
|
||||
|
||||
class ForgotPasswordResetResponse(BaseModel):
|
||||
result: str = Field(description="Operation result")
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
ForgotPasswordSendPayload,
|
||||
ForgotPasswordCheckPayload,
|
||||
ForgotPasswordResetPayload,
|
||||
ForgotPasswordEmailResponse,
|
||||
ForgotPasswordCheckResponse,
|
||||
ForgotPasswordResetResponse,
|
||||
)
|
||||
for model in (ForgotPasswordSendPayload, ForgotPasswordCheckPayload, ForgotPasswordResetPayload):
|
||||
console_ns.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0))
|
||||
|
||||
|
||||
@console_ns.route("/forgot-password")
|
||||
@@ -84,7 +60,14 @@ class ForgotPasswordSendEmailApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Email sent successfully",
|
||||
console_ns.models[ForgotPasswordEmailResponse.__name__],
|
||||
console_ns.model(
|
||||
"ForgotPasswordEmailResponse",
|
||||
{
|
||||
"result": fields.String(description="Operation result"),
|
||||
"data": fields.String(description="Reset token"),
|
||||
"code": fields.String(description="Error code if account not found"),
|
||||
},
|
||||
),
|
||||
)
|
||||
@console_ns.response(400, "Invalid email or rate limit exceeded")
|
||||
@setup_required
|
||||
@@ -123,7 +106,14 @@ class ForgotPasswordCheckApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Code verified successfully",
|
||||
console_ns.models[ForgotPasswordCheckResponse.__name__],
|
||||
console_ns.model(
|
||||
"ForgotPasswordCheckResponse",
|
||||
{
|
||||
"is_valid": fields.Boolean(description="Whether code is valid"),
|
||||
"email": fields.String(description="Email address"),
|
||||
"token": fields.String(description="New reset token"),
|
||||
},
|
||||
),
|
||||
)
|
||||
@console_ns.response(400, "Invalid code or token")
|
||||
@setup_required
|
||||
@@ -173,7 +163,7 @@ class ForgotPasswordResetApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Password reset successfully",
|
||||
console_ns.models[ForgotPasswordResetResponse.__name__],
|
||||
console_ns.model("ForgotPasswordResetResponse", {"result": fields.String(description="Operation result")}),
|
||||
)
|
||||
@console_ns.response(400, "Invalid token or password mismatch")
|
||||
@setup_required
|
||||
|
||||
@@ -155,43 +155,43 @@ class OAuthServerUserTokenApi(Resource):
|
||||
grant_type = OAuthGrantType(payload.grant_type)
|
||||
except ValueError:
|
||||
raise BadRequest("invalid grant_type")
|
||||
match grant_type:
|
||||
case OAuthGrantType.AUTHORIZATION_CODE:
|
||||
if not payload.code:
|
||||
raise BadRequest("code is required")
|
||||
|
||||
if payload.client_secret != oauth_provider_app.client_secret:
|
||||
raise BadRequest("client_secret is invalid")
|
||||
if grant_type == OAuthGrantType.AUTHORIZATION_CODE:
|
||||
if not payload.code:
|
||||
raise BadRequest("code is required")
|
||||
|
||||
if payload.redirect_uri not in oauth_provider_app.redirect_uris:
|
||||
raise BadRequest("redirect_uri is invalid")
|
||||
if payload.client_secret != oauth_provider_app.client_secret:
|
||||
raise BadRequest("client_secret is invalid")
|
||||
|
||||
access_token, refresh_token = OAuthServerService.sign_oauth_access_token(
|
||||
grant_type, code=payload.code, client_id=oauth_provider_app.client_id
|
||||
)
|
||||
return jsonable_encoder(
|
||||
{
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": OAUTH_ACCESS_TOKEN_EXPIRES_IN,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
)
|
||||
case OAuthGrantType.REFRESH_TOKEN:
|
||||
if not payload.refresh_token:
|
||||
raise BadRequest("refresh_token is required")
|
||||
if payload.redirect_uri not in oauth_provider_app.redirect_uris:
|
||||
raise BadRequest("redirect_uri is invalid")
|
||||
|
||||
access_token, refresh_token = OAuthServerService.sign_oauth_access_token(
|
||||
grant_type, refresh_token=payload.refresh_token, client_id=oauth_provider_app.client_id
|
||||
)
|
||||
return jsonable_encoder(
|
||||
{
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": OAUTH_ACCESS_TOKEN_EXPIRES_IN,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
)
|
||||
access_token, refresh_token = OAuthServerService.sign_oauth_access_token(
|
||||
grant_type, code=payload.code, client_id=oauth_provider_app.client_id
|
||||
)
|
||||
return jsonable_encoder(
|
||||
{
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": OAUTH_ACCESS_TOKEN_EXPIRES_IN,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
)
|
||||
elif grant_type == OAuthGrantType.REFRESH_TOKEN:
|
||||
if not payload.refresh_token:
|
||||
raise BadRequest("refresh_token is required")
|
||||
|
||||
access_token, refresh_token = OAuthServerService.sign_oauth_access_token(
|
||||
grant_type, refresh_token=payload.refresh_token, client_id=oauth_provider_app.client_id
|
||||
)
|
||||
return jsonable_encoder(
|
||||
{
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": OAUTH_ACCESS_TOKEN_EXPIRES_IN,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/oauth/provider/account")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
from collections.abc import Generator
|
||||
from typing import Any, Literal, cast
|
||||
from typing import Any, cast
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource, fields, marshal_with
|
||||
@@ -157,8 +157,9 @@ class DataSourceApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def patch(self, binding_id, action: Literal["enable", "disable"]):
|
||||
def patch(self, binding_id, action):
|
||||
binding_id = str(binding_id)
|
||||
action = str(action)
|
||||
with Session(db.engine) as session:
|
||||
data_source_binding = session.execute(
|
||||
select(DataSourceOauthBinding).filter_by(id=binding_id)
|
||||
@@ -166,24 +167,23 @@ class DataSourceApi(Resource):
|
||||
if data_source_binding is None:
|
||||
raise NotFound("Data source binding not found.")
|
||||
# enable binding
|
||||
match action:
|
||||
case "enable":
|
||||
if data_source_binding.disabled:
|
||||
data_source_binding.disabled = False
|
||||
data_source_binding.updated_at = naive_utc_now()
|
||||
db.session.add(data_source_binding)
|
||||
db.session.commit()
|
||||
else:
|
||||
raise ValueError("Data source is not disabled.")
|
||||
# disable binding
|
||||
case "disable":
|
||||
if not data_source_binding.disabled:
|
||||
data_source_binding.disabled = True
|
||||
data_source_binding.updated_at = naive_utc_now()
|
||||
db.session.add(data_source_binding)
|
||||
db.session.commit()
|
||||
else:
|
||||
raise ValueError("Data source is disabled.")
|
||||
if action == "enable":
|
||||
if data_source_binding.disabled:
|
||||
data_source_binding.disabled = False
|
||||
data_source_binding.updated_at = naive_utc_now()
|
||||
db.session.add(data_source_binding)
|
||||
db.session.commit()
|
||||
else:
|
||||
raise ValueError("Data source is not disabled.")
|
||||
# disable binding
|
||||
if action == "disable":
|
||||
if not data_source_binding.disabled:
|
||||
data_source_binding.disabled = True
|
||||
data_source_binding.updated_at = naive_utc_now()
|
||||
db.session.add(data_source_binding)
|
||||
db.session.commit()
|
||||
else:
|
||||
raise ValueError("Data source is disabled.")
|
||||
return {"result": "success"}, 200
|
||||
|
||||
|
||||
|
||||
@@ -576,62 +576,63 @@ class DocumentBatchIndexingEstimateApi(DocumentResource):
|
||||
if document.indexing_status in {"completed", "error"}:
|
||||
raise DocumentAlreadyFinishedError()
|
||||
data_source_info = document.data_source_info_dict
|
||||
match document.data_source_type:
|
||||
case "upload_file":
|
||||
if not data_source_info:
|
||||
continue
|
||||
file_id = data_source_info["upload_file_id"]
|
||||
file_detail = (
|
||||
db.session.query(UploadFile)
|
||||
.where(UploadFile.tenant_id == current_tenant_id, UploadFile.id == file_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if file_detail is None:
|
||||
raise NotFound("File not found.")
|
||||
if document.data_source_type == "upload_file":
|
||||
if not data_source_info:
|
||||
continue
|
||||
file_id = data_source_info["upload_file_id"]
|
||||
file_detail = (
|
||||
db.session.query(UploadFile)
|
||||
.where(UploadFile.tenant_id == current_tenant_id, UploadFile.id == file_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
extract_setting = ExtractSetting(
|
||||
datasource_type=DatasourceType.FILE, upload_file=file_detail, document_model=document.doc_form
|
||||
)
|
||||
extract_settings.append(extract_setting)
|
||||
case "notion_import":
|
||||
if not data_source_info:
|
||||
continue
|
||||
extract_setting = ExtractSetting(
|
||||
datasource_type=DatasourceType.NOTION,
|
||||
notion_info=NotionInfo.model_validate(
|
||||
{
|
||||
"credential_id": data_source_info.get("credential_id"),
|
||||
"notion_workspace_id": data_source_info["notion_workspace_id"],
|
||||
"notion_obj_id": data_source_info["notion_page_id"],
|
||||
"notion_page_type": data_source_info["type"],
|
||||
"tenant_id": current_tenant_id,
|
||||
}
|
||||
),
|
||||
document_model=document.doc_form,
|
||||
)
|
||||
extract_settings.append(extract_setting)
|
||||
case "website_crawl":
|
||||
if not data_source_info:
|
||||
continue
|
||||
extract_setting = ExtractSetting(
|
||||
datasource_type=DatasourceType.WEBSITE,
|
||||
website_info=WebsiteInfo.model_validate(
|
||||
{
|
||||
"provider": data_source_info["provider"],
|
||||
"job_id": data_source_info["job_id"],
|
||||
"url": data_source_info["url"],
|
||||
"tenant_id": current_tenant_id,
|
||||
"mode": data_source_info["mode"],
|
||||
"only_main_content": data_source_info["only_main_content"],
|
||||
}
|
||||
),
|
||||
document_model=document.doc_form,
|
||||
)
|
||||
extract_settings.append(extract_setting)
|
||||
if file_detail is None:
|
||||
raise NotFound("File not found.")
|
||||
|
||||
case _:
|
||||
raise ValueError("Data source type not support")
|
||||
extract_setting = ExtractSetting(
|
||||
datasource_type=DatasourceType.FILE, upload_file=file_detail, document_model=document.doc_form
|
||||
)
|
||||
extract_settings.append(extract_setting)
|
||||
|
||||
elif document.data_source_type == "notion_import":
|
||||
if not data_source_info:
|
||||
continue
|
||||
extract_setting = ExtractSetting(
|
||||
datasource_type=DatasourceType.NOTION,
|
||||
notion_info=NotionInfo.model_validate(
|
||||
{
|
||||
"credential_id": data_source_info.get("credential_id"),
|
||||
"notion_workspace_id": data_source_info["notion_workspace_id"],
|
||||
"notion_obj_id": data_source_info["notion_page_id"],
|
||||
"notion_page_type": data_source_info["type"],
|
||||
"tenant_id": current_tenant_id,
|
||||
}
|
||||
),
|
||||
document_model=document.doc_form,
|
||||
)
|
||||
extract_settings.append(extract_setting)
|
||||
elif document.data_source_type == "website_crawl":
|
||||
if not data_source_info:
|
||||
continue
|
||||
extract_setting = ExtractSetting(
|
||||
datasource_type=DatasourceType.WEBSITE,
|
||||
website_info=WebsiteInfo.model_validate(
|
||||
{
|
||||
"provider": data_source_info["provider"],
|
||||
"job_id": data_source_info["job_id"],
|
||||
"url": data_source_info["url"],
|
||||
"tenant_id": current_tenant_id,
|
||||
"mode": data_source_info["mode"],
|
||||
"only_main_content": data_source_info["only_main_content"],
|
||||
}
|
||||
),
|
||||
document_model=document.doc_form,
|
||||
)
|
||||
extract_settings.append(extract_setting)
|
||||
|
||||
else:
|
||||
raise ValueError("Data source type not support")
|
||||
indexing_runner = IndexingRunner()
|
||||
try:
|
||||
response = indexing_runner.indexing_estimate(
|
||||
@@ -953,24 +954,23 @@ class DocumentProcessingApi(DocumentResource):
|
||||
if not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
|
||||
match action:
|
||||
case "pause":
|
||||
if document.indexing_status != "indexing":
|
||||
raise InvalidActionError("Document not in indexing state.")
|
||||
if action == "pause":
|
||||
if document.indexing_status != "indexing":
|
||||
raise InvalidActionError("Document not in indexing state.")
|
||||
|
||||
document.paused_by = current_user.id
|
||||
document.paused_at = naive_utc_now()
|
||||
document.is_paused = True
|
||||
db.session.commit()
|
||||
document.paused_by = current_user.id
|
||||
document.paused_at = naive_utc_now()
|
||||
document.is_paused = True
|
||||
db.session.commit()
|
||||
|
||||
case "resume":
|
||||
if document.indexing_status not in {"paused", "error"}:
|
||||
raise InvalidActionError("Document not in paused or error state.")
|
||||
elif action == "resume":
|
||||
if document.indexing_status not in {"paused", "error"}:
|
||||
raise InvalidActionError("Document not in paused or error state.")
|
||||
|
||||
document.paused_by = None
|
||||
document.paused_at = None
|
||||
document.is_paused = False
|
||||
db.session.commit()
|
||||
document.paused_by = None
|
||||
document.paused_at = None
|
||||
document.is_paused = False
|
||||
db.session.commit()
|
||||
|
||||
return {"result": "success"}, 200
|
||||
|
||||
@@ -1339,18 +1339,6 @@ class DocumentGenerateSummaryApi(Resource):
|
||||
missing_ids = set(document_list) - found_ids
|
||||
raise NotFound(f"Some documents not found: {list(missing_ids)}")
|
||||
|
||||
# Update need_summary to True for documents that don't have it set
|
||||
# This handles the case where documents were created when summary_index_setting was disabled
|
||||
documents_to_update = [doc for doc in documents if not doc.need_summary and doc.doc_form != "qa_model"]
|
||||
|
||||
if documents_to_update:
|
||||
document_ids_to_update = [str(doc.id) for doc in documents_to_update]
|
||||
DocumentService.update_documents_need_summary(
|
||||
dataset_id=dataset_id,
|
||||
document_ids=document_ids_to_update,
|
||||
need_summary=True,
|
||||
)
|
||||
|
||||
# Dispatch async tasks for each document
|
||||
for document in documents:
|
||||
# Skip qa_model documents as they don't generate summaries
|
||||
|
||||
@@ -126,11 +126,10 @@ class DatasetMetadataBuiltInFieldActionApi(Resource):
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
|
||||
match action:
|
||||
case "enable":
|
||||
MetadataService.enable_built_in_field(dataset)
|
||||
case "disable":
|
||||
MetadataService.disable_built_in_field(dataset)
|
||||
if action == "enable":
|
||||
MetadataService.enable_built_in_field(dataset)
|
||||
elif action == "disable":
|
||||
MetadataService.disable_built_in_field(dataset)
|
||||
return {"result": "success"}, 200
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import services
|
||||
from controllers.common.fields import Parameters as ParametersResponse
|
||||
from controllers.common.fields import Site as SiteResponse
|
||||
from controllers.common.schema import get_or_create_model
|
||||
from controllers.console import api
|
||||
from controllers.console import api, console_ns
|
||||
from controllers.console.app.error import (
|
||||
AppUnavailableError,
|
||||
AudioTooLargeError,
|
||||
@@ -51,7 +51,7 @@ from fields.app_fields import (
|
||||
tag_fields,
|
||||
)
|
||||
from fields.dataset_fields import dataset_fields
|
||||
from fields.member_fields import simple_account_fields
|
||||
from fields.member_fields import build_simple_account_model
|
||||
from fields.workflow_fields import (
|
||||
conversation_variable_fields,
|
||||
pipeline_variable_fields,
|
||||
@@ -103,7 +103,7 @@ app_detail_fields_with_site_copy["tags"] = fields.List(fields.Nested(tag_model))
|
||||
app_detail_fields_with_site_copy["site"] = fields.Nested(site_model)
|
||||
app_detail_with_site_model = get_or_create_model("TrialAppDetailWithSite", app_detail_fields_with_site_copy)
|
||||
|
||||
simple_account_model = get_or_create_model("SimpleAccount", simple_account_fields)
|
||||
simple_account_model = build_simple_account_model(console_ns)
|
||||
conversation_variable_model = get_or_create_model("TrialConversationVariable", conversation_variable_fields)
|
||||
pipeline_variable_model = get_or_create_model("TrialPipelineVariable", pipeline_variable_fields)
|
||||
|
||||
|
||||
@@ -1,60 +1,58 @@
|
||||
from flask_restx import Resource, fields
|
||||
from pydantic import BaseModel, Field
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
|
||||
from controllers.fastopenapi import console_router
|
||||
from libs.login import current_account_with_tenant, current_user, login_required
|
||||
from services.feature_service import FeatureService
|
||||
from services.feature_service import FeatureModel, FeatureService, SystemFeatureModel
|
||||
|
||||
from . import console_ns
|
||||
from .wraps import account_initialization_required, cloud_utm_record, setup_required
|
||||
|
||||
|
||||
@console_ns.route("/features")
|
||||
class FeatureApi(Resource):
|
||||
@console_ns.doc("get_tenant_features")
|
||||
@console_ns.doc(description="Get feature configuration for current tenant")
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Success",
|
||||
console_ns.model("FeatureResponse", {"features": fields.Raw(description="Feature configuration object")}),
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_utm_record
|
||||
def get(self):
|
||||
"""Get feature configuration for current tenant"""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
return FeatureService.get_features(current_tenant_id).model_dump()
|
||||
class FeatureResponse(BaseModel):
|
||||
features: FeatureModel = Field(description="Feature configuration object")
|
||||
|
||||
|
||||
@console_ns.route("/system-features")
|
||||
class SystemFeatureApi(Resource):
|
||||
@console_ns.doc("get_system_features")
|
||||
@console_ns.doc(description="Get system-wide feature configuration")
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Success",
|
||||
console_ns.model(
|
||||
"SystemFeatureResponse", {"features": fields.Raw(description="System feature configuration object")}
|
||||
),
|
||||
)
|
||||
def get(self):
|
||||
"""Get system-wide feature configuration
|
||||
class SystemFeatureResponse(BaseModel):
|
||||
features: SystemFeatureModel = Field(description="System feature configuration object")
|
||||
|
||||
NOTE: This endpoint is unauthenticated by design, as it provides system features
|
||||
data required for dashboard initialization.
|
||||
|
||||
Authentication would create circular dependency (can't login without dashboard loading).
|
||||
@console_router.get(
|
||||
"/features",
|
||||
response_model=FeatureResponse,
|
||||
tags=["console"],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_utm_record
|
||||
def get_tenant_features() -> FeatureResponse:
|
||||
"""Get feature configuration for current tenant."""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
Only non-sensitive configuration data should be returned by this endpoint.
|
||||
"""
|
||||
# NOTE(QuantumGhost): ideally we should access `current_user.is_authenticated`
|
||||
# without a try-catch. However, due to the implementation of user loader (the `load_user_from_request`
|
||||
# in api/extensions/ext_login.py), accessing `current_user.is_authenticated` will
|
||||
# raise `Unauthorized` exception if authentication token is not provided.
|
||||
try:
|
||||
is_authenticated = current_user.is_authenticated
|
||||
except Unauthorized:
|
||||
is_authenticated = False
|
||||
return FeatureService.get_system_features(is_authenticated=is_authenticated).model_dump()
|
||||
return FeatureResponse(features=FeatureService.get_features(current_tenant_id))
|
||||
|
||||
|
||||
@console_router.get(
|
||||
"/system-features",
|
||||
response_model=SystemFeatureResponse,
|
||||
tags=["console"],
|
||||
)
|
||||
def get_system_features() -> SystemFeatureResponse:
|
||||
"""Get system-wide feature configuration
|
||||
|
||||
NOTE: This endpoint is unauthenticated by design, as it provides system features
|
||||
data required for dashboard initialization.
|
||||
|
||||
Authentication would create circular dependency (can't login without dashboard loading).
|
||||
|
||||
Only non-sensitive configuration data should be returned by this endpoint.
|
||||
"""
|
||||
# NOTE(QuantumGhost): ideally we should access `current_user.is_authenticated`
|
||||
# without a try-catch. However, due to the implementation of user loader (the `load_user_from_request`
|
||||
# in api/extensions/ext_login.py), accessing `current_user.is_authenticated` will
|
||||
# raise `Unauthorized` exception if authentication token is not provided.
|
||||
try:
|
||||
is_authenticated = current_user.is_authenticated
|
||||
except Unauthorized:
|
||||
is_authenticated = False
|
||||
return SystemFeatureResponse(features=FeatureService.get_system_features(is_authenticated=is_authenticated))
|
||||
|
||||
@@ -12,7 +12,6 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from constants.languages import supported_language
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.auth.error import (
|
||||
EmailAlreadyInUseError,
|
||||
@@ -38,7 +37,7 @@ from controllers.console.wraps import (
|
||||
setup_required,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from fields.member_fields import Account as AccountResponse
|
||||
from fields.member_fields import account_fields
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import EmailStr, TimestampField, extract_remote_ip, timezone
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
@@ -171,12 +170,6 @@ reg(ChangeEmailSendPayload)
|
||||
reg(ChangeEmailValidityPayload)
|
||||
reg(ChangeEmailResetPayload)
|
||||
reg(CheckEmailUniquePayload)
|
||||
register_schema_models(console_ns, AccountResponse)
|
||||
|
||||
|
||||
def _serialize_account(account) -> dict:
|
||||
return AccountResponse.model_validate(account, from_attributes=True).model_dump(mode="json")
|
||||
|
||||
|
||||
integrate_fields = {
|
||||
"provider": fields.String,
|
||||
@@ -243,11 +236,11 @@ class AccountProfileApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@marshal_with(account_fields)
|
||||
@enterprise_license_required
|
||||
def get(self):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
return _serialize_account(current_user)
|
||||
return current_user
|
||||
|
||||
|
||||
@console_ns.route("/account/name")
|
||||
@@ -256,14 +249,14 @@ class AccountNameApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@marshal_with(account_fields)
|
||||
def post(self):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountNamePayload.model_validate(payload)
|
||||
updated_account = AccountService.update_account(current_user, name=args.name)
|
||||
|
||||
return _serialize_account(updated_account)
|
||||
return updated_account
|
||||
|
||||
|
||||
@console_ns.route("/account/avatar")
|
||||
@@ -272,7 +265,7 @@ class AccountAvatarApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@marshal_with(account_fields)
|
||||
def post(self):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
payload = console_ns.payload or {}
|
||||
@@ -280,7 +273,7 @@ class AccountAvatarApi(Resource):
|
||||
|
||||
updated_account = AccountService.update_account(current_user, avatar=args.avatar)
|
||||
|
||||
return _serialize_account(updated_account)
|
||||
return updated_account
|
||||
|
||||
|
||||
@console_ns.route("/account/interface-language")
|
||||
@@ -289,7 +282,7 @@ class AccountInterfaceLanguageApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@marshal_with(account_fields)
|
||||
def post(self):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
payload = console_ns.payload or {}
|
||||
@@ -297,7 +290,7 @@ class AccountInterfaceLanguageApi(Resource):
|
||||
|
||||
updated_account = AccountService.update_account(current_user, interface_language=args.interface_language)
|
||||
|
||||
return _serialize_account(updated_account)
|
||||
return updated_account
|
||||
|
||||
|
||||
@console_ns.route("/account/interface-theme")
|
||||
@@ -306,7 +299,7 @@ class AccountInterfaceThemeApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@marshal_with(account_fields)
|
||||
def post(self):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
payload = console_ns.payload or {}
|
||||
@@ -314,7 +307,7 @@ class AccountInterfaceThemeApi(Resource):
|
||||
|
||||
updated_account = AccountService.update_account(current_user, interface_theme=args.interface_theme)
|
||||
|
||||
return _serialize_account(updated_account)
|
||||
return updated_account
|
||||
|
||||
|
||||
@console_ns.route("/account/timezone")
|
||||
@@ -323,7 +316,7 @@ class AccountTimezoneApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@marshal_with(account_fields)
|
||||
def post(self):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
payload = console_ns.payload or {}
|
||||
@@ -331,7 +324,7 @@ class AccountTimezoneApi(Resource):
|
||||
|
||||
updated_account = AccountService.update_account(current_user, timezone=args.timezone)
|
||||
|
||||
return _serialize_account(updated_account)
|
||||
return updated_account
|
||||
|
||||
|
||||
@console_ns.route("/account/password")
|
||||
@@ -340,7 +333,7 @@ class AccountPasswordApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@marshal_with(account_fields)
|
||||
def post(self):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
payload = console_ns.payload or {}
|
||||
@@ -351,7 +344,7 @@ class AccountPasswordApi(Resource):
|
||||
except ServiceCurrentPasswordIncorrectError:
|
||||
raise CurrentPasswordIncorrectError()
|
||||
|
||||
return _serialize_account(current_user)
|
||||
return {"result": "success"}
|
||||
|
||||
|
||||
@console_ns.route("/account/integrates")
|
||||
@@ -627,7 +620,7 @@ class ChangeEmailResetApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@marshal_with(account_fields)
|
||||
def post(self):
|
||||
payload = console_ns.payload or {}
|
||||
args = ChangeEmailResetPayload.model_validate(payload)
|
||||
@@ -656,7 +649,7 @@ class ChangeEmailResetApi(Resource):
|
||||
email=normalized_new_email,
|
||||
)
|
||||
|
||||
return _serialize_account(updated_account)
|
||||
return updated_account
|
||||
|
||||
|
||||
@console_ns.route("/account/change-email/check-email-unique")
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
from typing import Any
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from flask_restx import Resource, fields
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import account_initialization_required, is_admin_or_owner_required, setup_required
|
||||
from core.model_runtime.utils.encoders import jsonable_encoder
|
||||
@@ -39,53 +38,15 @@ class EndpointListForPluginQuery(EndpointListQuery):
|
||||
plugin_id: str
|
||||
|
||||
|
||||
class EndpointCreateResponse(BaseModel):
|
||||
success: bool = Field(description="Operation success")
|
||||
|
||||
|
||||
class EndpointListResponse(BaseModel):
|
||||
endpoints: list[dict[str, Any]] = Field(description="Endpoint information")
|
||||
|
||||
|
||||
class PluginEndpointListResponse(BaseModel):
|
||||
endpoints: list[dict[str, Any]] = Field(description="Endpoint information")
|
||||
|
||||
|
||||
class EndpointDeleteResponse(BaseModel):
|
||||
success: bool = Field(description="Operation success")
|
||||
|
||||
|
||||
class EndpointUpdateResponse(BaseModel):
|
||||
success: bool = Field(description="Operation success")
|
||||
|
||||
|
||||
class EndpointEnableResponse(BaseModel):
|
||||
success: bool = Field(description="Operation success")
|
||||
|
||||
|
||||
class EndpointDisableResponse(BaseModel):
|
||||
success: bool = Field(description="Operation success")
|
||||
|
||||
|
||||
def reg(cls: type[BaseModel]):
|
||||
console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0))
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
EndpointCreatePayload,
|
||||
EndpointIdPayload,
|
||||
EndpointUpdatePayload,
|
||||
EndpointListQuery,
|
||||
EndpointListForPluginQuery,
|
||||
EndpointCreateResponse,
|
||||
EndpointListResponse,
|
||||
PluginEndpointListResponse,
|
||||
EndpointDeleteResponse,
|
||||
EndpointUpdateResponse,
|
||||
EndpointEnableResponse,
|
||||
EndpointDisableResponse,
|
||||
)
|
||||
reg(EndpointCreatePayload)
|
||||
reg(EndpointIdPayload)
|
||||
reg(EndpointUpdatePayload)
|
||||
reg(EndpointListQuery)
|
||||
reg(EndpointListForPluginQuery)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/endpoints/create")
|
||||
@@ -96,7 +57,7 @@ class EndpointCreateApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Endpoint created successfully",
|
||||
console_ns.models[EndpointCreateResponse.__name__],
|
||||
console_ns.model("EndpointCreateResponse", {"success": fields.Boolean(description="Operation success")}),
|
||||
)
|
||||
@console_ns.response(403, "Admin privileges required")
|
||||
@setup_required
|
||||
@@ -130,7 +91,9 @@ class EndpointListApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Success",
|
||||
console_ns.models[EndpointListResponse.__name__],
|
||||
console_ns.model(
|
||||
"EndpointListResponse", {"endpoints": fields.List(fields.Raw(description="Endpoint information"))}
|
||||
),
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@@ -163,7 +126,9 @@ class EndpointListForSinglePluginApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Success",
|
||||
console_ns.models[PluginEndpointListResponse.__name__],
|
||||
console_ns.model(
|
||||
"PluginEndpointListResponse", {"endpoints": fields.List(fields.Raw(description="Endpoint information"))}
|
||||
),
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@@ -198,7 +163,7 @@ class EndpointDeleteApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Endpoint deleted successfully",
|
||||
console_ns.models[EndpointDeleteResponse.__name__],
|
||||
console_ns.model("EndpointDeleteResponse", {"success": fields.Boolean(description="Operation success")}),
|
||||
)
|
||||
@console_ns.response(403, "Admin privileges required")
|
||||
@setup_required
|
||||
@@ -225,7 +190,7 @@ class EndpointUpdateApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Endpoint updated successfully",
|
||||
console_ns.models[EndpointUpdateResponse.__name__],
|
||||
console_ns.model("EndpointUpdateResponse", {"success": fields.Boolean(description="Operation success")}),
|
||||
)
|
||||
@console_ns.response(403, "Admin privileges required")
|
||||
@setup_required
|
||||
@@ -256,7 +221,7 @@ class EndpointEnableApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Endpoint enabled successfully",
|
||||
console_ns.models[EndpointEnableResponse.__name__],
|
||||
console_ns.model("EndpointEnableResponse", {"success": fields.Boolean(description="Operation success")}),
|
||||
)
|
||||
@console_ns.response(403, "Admin privileges required")
|
||||
@setup_required
|
||||
@@ -283,7 +248,7 @@ class EndpointDisableApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Endpoint disabled successfully",
|
||||
console_ns.models[EndpointDisableResponse.__name__],
|
||||
console_ns.model("EndpointDisableResponse", {"success": fields.Boolean(description="Operation success")}),
|
||||
)
|
||||
@console_ns.response(403, "Admin privileges required")
|
||||
@setup_required
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from urllib import parse
|
||||
|
||||
from flask import abort, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, TypeAdapter
|
||||
from flask_restx import Resource, fields, marshal_with
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
import services
|
||||
from configs import dify_config
|
||||
from controllers.common.schema import register_enum_models, register_schema_models
|
||||
from controllers.common.schema import get_or_create_model, register_enum_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.auth.error import (
|
||||
CannotTransferOwnerToSelfError,
|
||||
@@ -25,7 +25,7 @@ from controllers.console.wraps import (
|
||||
setup_required,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from fields.member_fields import AccountWithRole, AccountWithRoleList
|
||||
from fields.member_fields import account_with_role_fields, account_with_role_list_fields
|
||||
from libs.helper import extract_remote_ip
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models.account import Account, TenantAccountRole
|
||||
@@ -69,7 +69,12 @@ reg(OwnerTransferEmailPayload)
|
||||
reg(OwnerTransferCheckPayload)
|
||||
reg(OwnerTransferPayload)
|
||||
register_enum_models(console_ns, TenantAccountRole)
|
||||
register_schema_models(console_ns, AccountWithRole, AccountWithRoleList)
|
||||
|
||||
account_with_role_model = get_or_create_model("AccountWithRole", account_with_role_fields)
|
||||
|
||||
account_with_role_list_fields_copy = account_with_role_list_fields.copy()
|
||||
account_with_role_list_fields_copy["accounts"] = fields.List(fields.Nested(account_with_role_model))
|
||||
account_with_role_list_model = get_or_create_model("AccountWithRoleList", account_with_role_list_fields_copy)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/members")
|
||||
@@ -79,15 +84,13 @@ class MemberListApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(200, "Success", console_ns.models[AccountWithRoleList.__name__])
|
||||
@marshal_with(account_with_role_list_model)
|
||||
def get(self):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
if not current_user.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
members = TenantService.get_tenant_members(current_user.current_tenant)
|
||||
member_models = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True)
|
||||
response = AccountWithRoleList(accounts=member_models)
|
||||
return response.model_dump(mode="json"), 200
|
||||
return {"result": "success", "accounts": members}, 200
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/members/invite-email")
|
||||
@@ -232,15 +235,13 @@ class DatasetOperatorMemberListApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.response(200, "Success", console_ns.models[AccountWithRoleList.__name__])
|
||||
@marshal_with(account_with_role_list_model)
|
||||
def get(self):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
if not current_user.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
members = TenantService.get_dataset_operator_members(current_user.current_tenant)
|
||||
member_models = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True)
|
||||
response = AccountWithRoleList(accounts=member_models)
|
||||
return response.model_dump(mode="json"), 200
|
||||
return {"result": "success", "accounts": members}, 200
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/members/send-owner-transfer-confirm-email")
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
from typing import Literal
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from flask_restx import Namespace, Resource, fields
|
||||
from flask_restx.api import HTTPStatus
|
||||
from pydantic import BaseModel, Field, TypeAdapter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console.wraps import edit_permission_required
|
||||
from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.wraps import validate_app_token
|
||||
from extensions.ext_redis import redis_client
|
||||
from fields.annotation_fields import Annotation, AnnotationList
|
||||
from fields.annotation_fields import annotation_fields, build_annotation_model
|
||||
from models.model import App
|
||||
from services.annotation_service import AppAnnotationService
|
||||
|
||||
@@ -26,9 +26,7 @@ class AnnotationReplyActionPayload(BaseModel):
|
||||
embedding_model_name: str = Field(description="Embedding model name")
|
||||
|
||||
|
||||
register_schema_models(
|
||||
service_api_ns, AnnotationCreatePayload, AnnotationReplyActionPayload, Annotation, AnnotationList
|
||||
)
|
||||
register_schema_models(service_api_ns, AnnotationCreatePayload, AnnotationReplyActionPayload)
|
||||
|
||||
|
||||
@service_api_ns.route("/apps/annotation-reply/<string:action>")
|
||||
@@ -47,11 +45,10 @@ class AnnotationReplyActionApi(Resource):
|
||||
def post(self, app_model: App, action: Literal["enable", "disable"]):
|
||||
"""Enable or disable annotation reply feature."""
|
||||
args = AnnotationReplyActionPayload.model_validate(service_api_ns.payload or {}).model_dump()
|
||||
match action:
|
||||
case "enable":
|
||||
result = AppAnnotationService.enable_app_annotation(args, app_model.id)
|
||||
case "disable":
|
||||
result = AppAnnotationService.disable_app_annotation(app_model.id)
|
||||
if action == "enable":
|
||||
result = AppAnnotationService.enable_app_annotation(args, app_model.id)
|
||||
elif action == "disable":
|
||||
result = AppAnnotationService.disable_app_annotation(app_model.id)
|
||||
return result, 200
|
||||
|
||||
|
||||
@@ -85,6 +82,23 @@ class AnnotationReplyActionStatusApi(Resource):
|
||||
return {"job_id": job_id, "job_status": job_status, "error_msg": error_msg}, 200
|
||||
|
||||
|
||||
# Define annotation list response model
|
||||
annotation_list_fields = {
|
||||
"data": fields.List(fields.Nested(annotation_fields)),
|
||||
"has_more": fields.Boolean,
|
||||
"limit": fields.Integer,
|
||||
"total": fields.Integer,
|
||||
"page": fields.Integer,
|
||||
}
|
||||
|
||||
|
||||
def build_annotation_list_model(api_or_ns: Namespace):
|
||||
"""Build the annotation list model for the API or Namespace."""
|
||||
copied_annotation_list_fields = annotation_list_fields.copy()
|
||||
copied_annotation_list_fields["data"] = fields.List(fields.Nested(build_annotation_model(api_or_ns)))
|
||||
return api_or_ns.model("AnnotationList", copied_annotation_list_fields)
|
||||
|
||||
|
||||
@service_api_ns.route("/apps/annotations")
|
||||
class AnnotationListApi(Resource):
|
||||
@service_api_ns.doc("list_annotations")
|
||||
@@ -95,12 +109,8 @@ class AnnotationListApi(Resource):
|
||||
401: "Unauthorized - invalid API token",
|
||||
}
|
||||
)
|
||||
@service_api_ns.response(
|
||||
200,
|
||||
"Annotations retrieved successfully",
|
||||
service_api_ns.models[AnnotationList.__name__],
|
||||
)
|
||||
@validate_app_token
|
||||
@service_api_ns.marshal_with(build_annotation_list_model(service_api_ns))
|
||||
def get(self, app_model: App):
|
||||
"""List annotations for the application."""
|
||||
page = request.args.get("page", default=1, type=int)
|
||||
@@ -108,15 +118,13 @@ class AnnotationListApi(Resource):
|
||||
keyword = request.args.get("keyword", default="", type=str)
|
||||
|
||||
annotation_list, total = AppAnnotationService.get_annotation_list_by_app_id(app_model.id, page, limit, keyword)
|
||||
annotation_models = TypeAdapter(list[Annotation]).validate_python(annotation_list, from_attributes=True)
|
||||
response = AnnotationList(
|
||||
data=annotation_models,
|
||||
has_more=len(annotation_list) == limit,
|
||||
limit=limit,
|
||||
total=total,
|
||||
page=page,
|
||||
)
|
||||
return response.model_dump(mode="json")
|
||||
return {
|
||||
"data": annotation_list,
|
||||
"has_more": len(annotation_list) == limit,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"page": page,
|
||||
}
|
||||
|
||||
@service_api_ns.expect(service_api_ns.models[AnnotationCreatePayload.__name__])
|
||||
@service_api_ns.doc("create_annotation")
|
||||
@@ -127,18 +135,13 @@ class AnnotationListApi(Resource):
|
||||
401: "Unauthorized - invalid API token",
|
||||
}
|
||||
)
|
||||
@service_api_ns.response(
|
||||
HTTPStatus.CREATED,
|
||||
"Annotation created successfully",
|
||||
service_api_ns.models[Annotation.__name__],
|
||||
)
|
||||
@validate_app_token
|
||||
@service_api_ns.marshal_with(build_annotation_model(service_api_ns), code=HTTPStatus.CREATED)
|
||||
def post(self, app_model: App):
|
||||
"""Create a new annotation."""
|
||||
args = AnnotationCreatePayload.model_validate(service_api_ns.payload or {}).model_dump()
|
||||
annotation = AppAnnotationService.insert_app_annotation_directly(args, app_model.id)
|
||||
response = Annotation.model_validate(annotation, from_attributes=True)
|
||||
return response.model_dump(mode="json"), HTTPStatus.CREATED
|
||||
return annotation, 201
|
||||
|
||||
|
||||
@service_api_ns.route("/apps/annotations/<uuid:annotation_id>")
|
||||
@@ -155,19 +158,14 @@ class AnnotationUpdateDeleteApi(Resource):
|
||||
404: "Annotation not found",
|
||||
}
|
||||
)
|
||||
@service_api_ns.response(
|
||||
200,
|
||||
"Annotation updated successfully",
|
||||
service_api_ns.models[Annotation.__name__],
|
||||
)
|
||||
@validate_app_token
|
||||
@edit_permission_required
|
||||
@service_api_ns.marshal_with(build_annotation_model(service_api_ns))
|
||||
def put(self, app_model: App, annotation_id: str):
|
||||
"""Update an existing annotation."""
|
||||
args = AnnotationCreatePayload.model_validate(service_api_ns.payload or {}).model_dump()
|
||||
annotation = AppAnnotationService.update_app_annotation_directly(args, app_model.id, annotation_id)
|
||||
response = Annotation.model_validate(annotation, from_attributes=True)
|
||||
return response.model_dump(mode="json")
|
||||
return annotation
|
||||
|
||||
@service_api_ns.doc("delete_annotation")
|
||||
@service_api_ns.doc(description="Delete an annotation")
|
||||
|
||||
@@ -17,7 +17,7 @@ from controllers.service_api.wraps import (
|
||||
from core.model_runtime.entities.model_entities import ModelType
|
||||
from core.provider_manager import ProviderManager
|
||||
from fields.dataset_fields import dataset_detail_fields
|
||||
from fields.tag_fields import DataSetTag
|
||||
from fields.tag_fields import build_dataset_tag_fields
|
||||
from libs.login import current_user
|
||||
from models.account import Account
|
||||
from models.dataset import DatasetPermissionEnum
|
||||
@@ -114,7 +114,6 @@ register_schema_models(
|
||||
TagBindingPayload,
|
||||
TagUnbindingPayload,
|
||||
DatasetListQuery,
|
||||
DataSetTag,
|
||||
)
|
||||
|
||||
|
||||
@@ -481,14 +480,15 @@ class DatasetTagsApi(DatasetApiResource):
|
||||
401: "Unauthorized - invalid API token",
|
||||
}
|
||||
)
|
||||
@service_api_ns.marshal_with(build_dataset_tag_fields(service_api_ns))
|
||||
def get(self, _):
|
||||
"""Get all knowledge type tags."""
|
||||
assert isinstance(current_user, Account)
|
||||
cid = current_user.current_tenant_id
|
||||
assert cid is not None
|
||||
tags = TagService.get_tags("knowledge", cid)
|
||||
tag_models = TypeAdapter(list[DataSetTag]).validate_python(tags, from_attributes=True)
|
||||
return [tag.model_dump(mode="json") for tag in tag_models], 200
|
||||
|
||||
return tags, 200
|
||||
|
||||
@service_api_ns.expect(service_api_ns.models[TagCreatePayload.__name__])
|
||||
@service_api_ns.doc("create_dataset_tag")
|
||||
@@ -500,6 +500,7 @@ class DatasetTagsApi(DatasetApiResource):
|
||||
403: "Forbidden - insufficient permissions",
|
||||
}
|
||||
)
|
||||
@service_api_ns.marshal_with(build_dataset_tag_fields(service_api_ns))
|
||||
def post(self, _):
|
||||
"""Add a knowledge type tag."""
|
||||
assert isinstance(current_user, Account)
|
||||
@@ -509,9 +510,7 @@ class DatasetTagsApi(DatasetApiResource):
|
||||
payload = TagCreatePayload.model_validate(service_api_ns.payload or {})
|
||||
tag = TagService.save_tags({"name": payload.name, "type": "knowledge"})
|
||||
|
||||
response = DataSetTag.model_validate(
|
||||
{"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": 0}
|
||||
).model_dump(mode="json")
|
||||
response = {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": 0}
|
||||
return response, 200
|
||||
|
||||
@service_api_ns.expect(service_api_ns.models[TagUpdatePayload.__name__])
|
||||
@@ -524,6 +523,7 @@ class DatasetTagsApi(DatasetApiResource):
|
||||
403: "Forbidden - insufficient permissions",
|
||||
}
|
||||
)
|
||||
@service_api_ns.marshal_with(build_dataset_tag_fields(service_api_ns))
|
||||
def patch(self, _):
|
||||
assert isinstance(current_user, Account)
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
@@ -536,9 +536,8 @@ class DatasetTagsApi(DatasetApiResource):
|
||||
|
||||
binding_count = TagService.get_tag_binding_count(tag_id)
|
||||
|
||||
response = DataSetTag.model_validate(
|
||||
{"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": binding_count}
|
||||
).model_dump(mode="json")
|
||||
response = {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": binding_count}
|
||||
|
||||
return response, 200
|
||||
|
||||
@service_api_ns.expect(service_api_ns.models[TagDeletePayload.__name__])
|
||||
|
||||
@@ -168,11 +168,10 @@ class DatasetMetadataBuiltInFieldActionServiceApi(DatasetApiResource):
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
|
||||
match action:
|
||||
case "enable":
|
||||
MetadataService.enable_built_in_field(dataset)
|
||||
case "disable":
|
||||
MetadataService.disable_built_in_field(dataset)
|
||||
if action == "enable":
|
||||
MetadataService.enable_built_in_field(dataset)
|
||||
elif action == "disable":
|
||||
MetadataService.disable_built_in_field(dataset)
|
||||
return {"result": "success"}, 200
|
||||
|
||||
|
||||
|
||||
@@ -73,14 +73,14 @@ def validate_app_token(view: Callable[P, R] | None = None, *, fetch_user_arg: Fe
|
||||
|
||||
# If caller needs end-user context, attach EndUser to current_user
|
||||
if fetch_user_arg:
|
||||
user_id = None
|
||||
match fetch_user_arg.fetch_from:
|
||||
case WhereisUserArg.QUERY:
|
||||
user_id = request.args.get("user")
|
||||
case WhereisUserArg.JSON:
|
||||
user_id = request.get_json().get("user")
|
||||
case WhereisUserArg.FORM:
|
||||
user_id = request.form.get("user")
|
||||
if fetch_user_arg.fetch_from == WhereisUserArg.QUERY:
|
||||
user_id = request.args.get("user")
|
||||
elif fetch_user_arg.fetch_from == WhereisUserArg.JSON:
|
||||
user_id = request.get_json().get("user")
|
||||
elif fetch_user_arg.fetch_from == WhereisUserArg.FORM:
|
||||
user_id = request.form.get("user")
|
||||
else:
|
||||
user_id = None
|
||||
|
||||
if not user_id and fetch_user_arg.required:
|
||||
raise ValueError("Arg user must be provided.")
|
||||
|
||||
@@ -14,17 +14,16 @@ class AgentConfigManager:
|
||||
agent_dict = config.get("agent_mode", {})
|
||||
agent_strategy = agent_dict.get("strategy", "cot")
|
||||
|
||||
match agent_strategy:
|
||||
case "function_call":
|
||||
if agent_strategy == "function_call":
|
||||
strategy = AgentEntity.Strategy.FUNCTION_CALLING
|
||||
elif agent_strategy in {"cot", "react"}:
|
||||
strategy = AgentEntity.Strategy.CHAIN_OF_THOUGHT
|
||||
else:
|
||||
# old configs, try to detect default strategy
|
||||
if config["model"]["provider"] == "openai":
|
||||
strategy = AgentEntity.Strategy.FUNCTION_CALLING
|
||||
case "cot" | "react":
|
||||
else:
|
||||
strategy = AgentEntity.Strategy.CHAIN_OF_THOUGHT
|
||||
case _:
|
||||
# old configs, try to detect default strategy
|
||||
if config["model"]["provider"] == "openai":
|
||||
strategy = AgentEntity.Strategy.FUNCTION_CALLING
|
||||
else:
|
||||
strategy = AgentEntity.Strategy.CHAIN_OF_THOUGHT
|
||||
|
||||
agent_tools = []
|
||||
for tool in agent_dict.get("tools", []):
|
||||
|
||||
@@ -250,7 +250,7 @@ class WorkflowResponseConverter:
|
||||
data=WorkflowFinishStreamResponse.Data(
|
||||
id=run_id,
|
||||
workflow_id=workflow_id,
|
||||
status=status,
|
||||
status=status.value,
|
||||
outputs=encoded_outputs,
|
||||
error=error,
|
||||
elapsed_time=elapsed_time,
|
||||
@@ -340,13 +340,13 @@ class WorkflowResponseConverter:
|
||||
metadata = self._merge_metadata(event.execution_metadata, snapshot)
|
||||
|
||||
if isinstance(event, QueueNodeSucceededEvent):
|
||||
status = WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
status = WorkflowNodeExecutionStatus.SUCCEEDED.value
|
||||
error_message = event.error
|
||||
elif isinstance(event, QueueNodeFailedEvent):
|
||||
status = WorkflowNodeExecutionStatus.FAILED
|
||||
status = WorkflowNodeExecutionStatus.FAILED.value
|
||||
error_message = event.error
|
||||
else:
|
||||
status = WorkflowNodeExecutionStatus.EXCEPTION
|
||||
status = WorkflowNodeExecutionStatus.EXCEPTION.value
|
||||
error_message = event.error
|
||||
|
||||
return NodeFinishStreamResponse(
|
||||
@@ -413,7 +413,7 @@ class WorkflowResponseConverter:
|
||||
process_data_truncated=process_data_truncated,
|
||||
outputs=outputs,
|
||||
outputs_truncated=outputs_truncated,
|
||||
status=WorkflowNodeExecutionStatus.RETRY,
|
||||
status=WorkflowNodeExecutionStatus.RETRY.value,
|
||||
error=event.error,
|
||||
elapsed_time=elapsed_time,
|
||||
execution_metadata=metadata,
|
||||
|
||||
@@ -120,7 +120,7 @@ class PipelineGenerator(BaseAppGenerator):
|
||||
raise ValueError("Pipeline dataset is required")
|
||||
inputs: Mapping[str, Any] = args["inputs"]
|
||||
start_node_id: str = args["start_node_id"]
|
||||
datasource_type = DatasourceProviderType(args["datasource_type"])
|
||||
datasource_type: str = args["datasource_type"]
|
||||
datasource_info_list: list[Mapping[str, Any]] = self._format_datasource_info_list(
|
||||
datasource_type, args["datasource_info_list"], pipeline, workflow, start_node_id, user
|
||||
)
|
||||
@@ -660,7 +660,7 @@ class PipelineGenerator(BaseAppGenerator):
|
||||
tenant_id: str,
|
||||
dataset_id: str,
|
||||
built_in_field_enabled: bool,
|
||||
datasource_type: DatasourceProviderType,
|
||||
datasource_type: str,
|
||||
datasource_info: Mapping[str, Any],
|
||||
created_from: str,
|
||||
position: int,
|
||||
@@ -668,17 +668,17 @@ class PipelineGenerator(BaseAppGenerator):
|
||||
batch: str,
|
||||
document_form: str,
|
||||
):
|
||||
match datasource_type:
|
||||
case DatasourceProviderType.LOCAL_FILE:
|
||||
name = datasource_info.get("name", "untitled")
|
||||
case DatasourceProviderType.ONLINE_DOCUMENT:
|
||||
name = datasource_info.get("page", {}).get("page_name", "untitled")
|
||||
case DatasourceProviderType.WEBSITE_CRAWL:
|
||||
name = datasource_info.get("title", "untitled")
|
||||
case DatasourceProviderType.ONLINE_DRIVE:
|
||||
name = datasource_info.get("name", "untitled")
|
||||
case _:
|
||||
raise ValueError(f"Unsupported datasource type: {datasource_type}")
|
||||
if datasource_type == "local_file":
|
||||
name = datasource_info.get("name", "untitled")
|
||||
elif datasource_type == "online_document":
|
||||
name = datasource_info.get("page", {}).get("page_name", "untitled")
|
||||
elif datasource_type == "website_crawl":
|
||||
name = datasource_info.get("title", "untitled")
|
||||
elif datasource_type == "online_drive":
|
||||
name = datasource_info.get("name", "untitled")
|
||||
else:
|
||||
raise ValueError(f"Unsupported datasource type: {datasource_type}")
|
||||
|
||||
document = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset_id,
|
||||
@@ -706,7 +706,7 @@ class PipelineGenerator(BaseAppGenerator):
|
||||
|
||||
def _format_datasource_info_list(
|
||||
self,
|
||||
datasource_type: DatasourceProviderType,
|
||||
datasource_type: str,
|
||||
datasource_info_list: list[Mapping[str, Any]],
|
||||
pipeline: Pipeline,
|
||||
workflow: Workflow,
|
||||
@@ -716,7 +716,7 @@ class PipelineGenerator(BaseAppGenerator):
|
||||
"""
|
||||
Format datasource info list.
|
||||
"""
|
||||
if datasource_type == DatasourceProviderType.ONLINE_DRIVE:
|
||||
if datasource_type == "online_drive":
|
||||
all_files: list[Mapping[str, Any]] = []
|
||||
datasource_node_data = None
|
||||
datasource_nodes = workflow.graph_dict.get("nodes", [])
|
||||
|
||||
@@ -7,7 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from core.model_runtime.entities.llm_entities import LLMResult, LLMUsage
|
||||
from core.rag.entities.citation_metadata import RetrievalSourceMetadata
|
||||
from core.workflow.entities import AgentNodeStrategyInit
|
||||
from core.workflow.enums import WorkflowExecutionStatus, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
|
||||
from core.workflow.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
|
||||
|
||||
|
||||
class AnnotationReplyAccount(BaseModel):
|
||||
@@ -223,7 +223,7 @@ class WorkflowFinishStreamResponse(StreamResponse):
|
||||
|
||||
id: str
|
||||
workflow_id: str
|
||||
status: WorkflowExecutionStatus
|
||||
status: str
|
||||
outputs: Mapping[str, Any] | None = None
|
||||
error: str | None = None
|
||||
elapsed_time: float
|
||||
@@ -311,7 +311,7 @@ class NodeFinishStreamResponse(StreamResponse):
|
||||
process_data_truncated: bool = False
|
||||
outputs: Mapping[str, Any] | None = None
|
||||
outputs_truncated: bool = True
|
||||
status: WorkflowNodeExecutionStatus
|
||||
status: str
|
||||
error: str | None = None
|
||||
elapsed_time: float
|
||||
execution_metadata: Mapping[WorkflowNodeExecutionMetadataKey, Any] | None = None
|
||||
@@ -375,7 +375,7 @@ class NodeRetryStreamResponse(StreamResponse):
|
||||
process_data_truncated: bool = False
|
||||
outputs: Mapping[str, Any] | None = None
|
||||
outputs_truncated: bool = False
|
||||
status: WorkflowNodeExecutionStatus
|
||||
status: str
|
||||
error: str | None = None
|
||||
elapsed_time: float
|
||||
execution_metadata: Mapping[WorkflowNodeExecutionMetadataKey, Any] | None = None
|
||||
@@ -719,7 +719,7 @@ class WorkflowAppBlockingResponse(AppBlockingResponse):
|
||||
|
||||
id: str
|
||||
workflow_id: str
|
||||
status: WorkflowExecutionStatus
|
||||
status: str
|
||||
outputs: Mapping[str, Any] | None = None
|
||||
error: str | None = None
|
||||
elapsed_time: float
|
||||
|
||||
+58
-59
@@ -369,78 +369,77 @@ class IndexingRunner:
|
||||
# Generate summary preview
|
||||
summary_index_setting = tmp_processing_rule.get("summary_index_setting")
|
||||
if summary_index_setting and summary_index_setting.get("enable") and preview_texts:
|
||||
preview_texts = index_processor.generate_summary_preview(
|
||||
tenant_id, preview_texts, summary_index_setting, doc_language
|
||||
)
|
||||
preview_texts = index_processor.generate_summary_preview(tenant_id, preview_texts, summary_index_setting)
|
||||
|
||||
return IndexingEstimate(total_segments=total_segments, preview=preview_texts)
|
||||
|
||||
def _extract(
|
||||
self, index_processor: BaseIndexProcessor, dataset_document: DatasetDocument, process_rule: dict
|
||||
) -> list[Document]:
|
||||
# load file
|
||||
if dataset_document.data_source_type not in {"upload_file", "notion_import", "website_crawl"}:
|
||||
return []
|
||||
|
||||
data_source_info = dataset_document.data_source_info_dict
|
||||
text_docs = []
|
||||
match dataset_document.data_source_type:
|
||||
case "upload_file":
|
||||
if not data_source_info or "upload_file_id" not in data_source_info:
|
||||
raise ValueError("no upload file found")
|
||||
stmt = select(UploadFile).where(UploadFile.id == data_source_info["upload_file_id"])
|
||||
file_detail = db.session.scalars(stmt).one_or_none()
|
||||
if dataset_document.data_source_type == "upload_file":
|
||||
if not data_source_info or "upload_file_id" not in data_source_info:
|
||||
raise ValueError("no upload file found")
|
||||
stmt = select(UploadFile).where(UploadFile.id == data_source_info["upload_file_id"])
|
||||
file_detail = db.session.scalars(stmt).one_or_none()
|
||||
|
||||
if file_detail:
|
||||
extract_setting = ExtractSetting(
|
||||
datasource_type=DatasourceType.FILE,
|
||||
upload_file=file_detail,
|
||||
document_model=dataset_document.doc_form,
|
||||
)
|
||||
text_docs = index_processor.extract(extract_setting, process_rule_mode=process_rule["mode"])
|
||||
case "notion_import":
|
||||
if (
|
||||
not data_source_info
|
||||
or "notion_workspace_id" not in data_source_info
|
||||
or "notion_page_id" not in data_source_info
|
||||
):
|
||||
raise ValueError("no notion import info found")
|
||||
if file_detail:
|
||||
extract_setting = ExtractSetting(
|
||||
datasource_type=DatasourceType.NOTION,
|
||||
notion_info=NotionInfo.model_validate(
|
||||
{
|
||||
"credential_id": data_source_info.get("credential_id"),
|
||||
"notion_workspace_id": data_source_info["notion_workspace_id"],
|
||||
"notion_obj_id": data_source_info["notion_page_id"],
|
||||
"notion_page_type": data_source_info["type"],
|
||||
"document": dataset_document,
|
||||
"tenant_id": dataset_document.tenant_id,
|
||||
}
|
||||
),
|
||||
datasource_type=DatasourceType.FILE,
|
||||
upload_file=file_detail,
|
||||
document_model=dataset_document.doc_form,
|
||||
)
|
||||
text_docs = index_processor.extract(extract_setting, process_rule_mode=process_rule["mode"])
|
||||
case "website_crawl":
|
||||
if (
|
||||
not data_source_info
|
||||
or "provider" not in data_source_info
|
||||
or "url" not in data_source_info
|
||||
or "job_id" not in data_source_info
|
||||
):
|
||||
raise ValueError("no website import info found")
|
||||
extract_setting = ExtractSetting(
|
||||
datasource_type=DatasourceType.WEBSITE,
|
||||
website_info=WebsiteInfo.model_validate(
|
||||
{
|
||||
"provider": data_source_info["provider"],
|
||||
"job_id": data_source_info["job_id"],
|
||||
"tenant_id": dataset_document.tenant_id,
|
||||
"url": data_source_info["url"],
|
||||
"mode": data_source_info["mode"],
|
||||
"only_main_content": data_source_info["only_main_content"],
|
||||
}
|
||||
),
|
||||
document_model=dataset_document.doc_form,
|
||||
)
|
||||
text_docs = index_processor.extract(extract_setting, process_rule_mode=process_rule["mode"])
|
||||
case _:
|
||||
return []
|
||||
elif dataset_document.data_source_type == "notion_import":
|
||||
if (
|
||||
not data_source_info
|
||||
or "notion_workspace_id" not in data_source_info
|
||||
or "notion_page_id" not in data_source_info
|
||||
):
|
||||
raise ValueError("no notion import info found")
|
||||
extract_setting = ExtractSetting(
|
||||
datasource_type=DatasourceType.NOTION,
|
||||
notion_info=NotionInfo.model_validate(
|
||||
{
|
||||
"credential_id": data_source_info.get("credential_id"),
|
||||
"notion_workspace_id": data_source_info["notion_workspace_id"],
|
||||
"notion_obj_id": data_source_info["notion_page_id"],
|
||||
"notion_page_type": data_source_info["type"],
|
||||
"document": dataset_document,
|
||||
"tenant_id": dataset_document.tenant_id,
|
||||
}
|
||||
),
|
||||
document_model=dataset_document.doc_form,
|
||||
)
|
||||
text_docs = index_processor.extract(extract_setting, process_rule_mode=process_rule["mode"])
|
||||
elif dataset_document.data_source_type == "website_crawl":
|
||||
if (
|
||||
not data_source_info
|
||||
or "provider" not in data_source_info
|
||||
or "url" not in data_source_info
|
||||
or "job_id" not in data_source_info
|
||||
):
|
||||
raise ValueError("no website import info found")
|
||||
extract_setting = ExtractSetting(
|
||||
datasource_type=DatasourceType.WEBSITE,
|
||||
website_info=WebsiteInfo.model_validate(
|
||||
{
|
||||
"provider": data_source_info["provider"],
|
||||
"job_id": data_source_info["job_id"],
|
||||
"tenant_id": dataset_document.tenant_id,
|
||||
"url": data_source_info["url"],
|
||||
"mode": data_source_info["mode"],
|
||||
"only_main_content": data_source_info["only_main_content"],
|
||||
}
|
||||
),
|
||||
document_model=dataset_document.doc_form,
|
||||
)
|
||||
text_docs = index_processor.extract(extract_setting, process_rule_mode=process_rule["mode"])
|
||||
# update document status to splitting
|
||||
self._update_document_index_status(
|
||||
document_id=dataset_document.id,
|
||||
|
||||
@@ -441,13 +441,11 @@ DEFAULT_GENERATOR_SUMMARY_PROMPT = (
|
||||
|
||||
Requirements:
|
||||
1. Write a concise summary in plain text
|
||||
2. You must write in {language}. No language other than {language} should be used.
|
||||
2. Use the same language as the input content
|
||||
3. Focus on important facts, concepts, and details
|
||||
4. If images are included, describe their key information
|
||||
5. Do not use words like "好的", "ok", "I understand", "This text discusses", "The content mentions"
|
||||
6. Write directly without extra words
|
||||
7. If there is not enough content to generate a meaningful summary,
|
||||
return an empty string without any explanation or prompt
|
||||
|
||||
Output only the summary text. Start summarizing now:
|
||||
|
||||
|
||||
@@ -48,22 +48,12 @@ class BaseIndexProcessor(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def generate_summary_preview(
|
||||
self,
|
||||
tenant_id: str,
|
||||
preview_texts: list[PreviewDetail],
|
||||
summary_index_setting: dict,
|
||||
doc_language: str | None = None,
|
||||
self, tenant_id: str, preview_texts: list[PreviewDetail], summary_index_setting: dict
|
||||
) -> list[PreviewDetail]:
|
||||
"""
|
||||
For each segment in preview_texts, generate a summary using LLM and attach it to the segment.
|
||||
The summary can be stored in a new attribute, e.g., summary.
|
||||
This method should be implemented by subclasses.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
preview_texts: List of preview details to generate summaries for
|
||||
summary_index_setting: Summary index configuration
|
||||
doc_language: Optional document language to ensure summary is generated in the correct language
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@@ -275,11 +275,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
|
||||
raise ValueError("Chunks is not a list")
|
||||
|
||||
def generate_summary_preview(
|
||||
self,
|
||||
tenant_id: str,
|
||||
preview_texts: list[PreviewDetail],
|
||||
summary_index_setting: dict,
|
||||
doc_language: str | None = None,
|
||||
self, tenant_id: str, preview_texts: list[PreviewDetail], summary_index_setting: dict
|
||||
) -> list[PreviewDetail]:
|
||||
"""
|
||||
For each segment, concurrently call generate_summary to generate a summary
|
||||
@@ -302,15 +298,11 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
|
||||
if flask_app:
|
||||
# Ensure Flask app context in worker thread
|
||||
with flask_app.app_context():
|
||||
summary, _ = self.generate_summary(
|
||||
tenant_id, preview.content, summary_index_setting, document_language=doc_language
|
||||
)
|
||||
summary, _ = self.generate_summary(tenant_id, preview.content, summary_index_setting)
|
||||
preview.summary = summary
|
||||
else:
|
||||
# Fallback: try without app context (may fail)
|
||||
summary, _ = self.generate_summary(
|
||||
tenant_id, preview.content, summary_index_setting, document_language=doc_language
|
||||
)
|
||||
summary, _ = self.generate_summary(tenant_id, preview.content, summary_index_setting)
|
||||
preview.summary = summary
|
||||
|
||||
# Generate summaries concurrently using ThreadPoolExecutor
|
||||
@@ -364,7 +356,6 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
|
||||
text: str,
|
||||
summary_index_setting: dict | None = None,
|
||||
segment_id: str | None = None,
|
||||
document_language: str | None = None,
|
||||
) -> tuple[str, LLMUsage]:
|
||||
"""
|
||||
Generate summary for the given text using ModelInstance.invoke_llm and the default or custom summary prompt,
|
||||
@@ -375,8 +366,6 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
|
||||
text: Text content to summarize
|
||||
summary_index_setting: Summary index configuration
|
||||
segment_id: Optional segment ID to fetch attachments from SegmentAttachmentBinding table
|
||||
document_language: Optional document language (e.g., "Chinese", "English")
|
||||
to ensure summary is generated in the correct language
|
||||
|
||||
Returns:
|
||||
Tuple of (summary_content, llm_usage) where llm_usage is LLMUsage object
|
||||
@@ -392,22 +381,8 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
|
||||
raise ValueError("model_name and model_provider_name are required in summary_index_setting")
|
||||
|
||||
# Import default summary prompt
|
||||
is_default_prompt = False
|
||||
if not summary_prompt:
|
||||
summary_prompt = DEFAULT_GENERATOR_SUMMARY_PROMPT
|
||||
is_default_prompt = True
|
||||
|
||||
# Format prompt with document language only for default prompt
|
||||
# Custom prompts are used as-is to avoid interfering with user-defined templates
|
||||
# If document_language is provided, use it; otherwise, use "the same language as the input content"
|
||||
# This is especially important for image-only chunks where text is empty or minimal
|
||||
if is_default_prompt:
|
||||
language_for_prompt = document_language or "the same language as the input content"
|
||||
try:
|
||||
summary_prompt = summary_prompt.format(language=language_for_prompt)
|
||||
except KeyError:
|
||||
# If default prompt doesn't have {language} placeholder, use it as-is
|
||||
pass
|
||||
|
||||
provider_manager = ProviderManager()
|
||||
provider_model_bundle = provider_manager.get_provider_model_bundle(
|
||||
|
||||
@@ -358,11 +358,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
|
||||
}
|
||||
|
||||
def generate_summary_preview(
|
||||
self,
|
||||
tenant_id: str,
|
||||
preview_texts: list[PreviewDetail],
|
||||
summary_index_setting: dict,
|
||||
doc_language: str | None = None,
|
||||
self, tenant_id: str, preview_texts: list[PreviewDetail], summary_index_setting: dict
|
||||
) -> list[PreviewDetail]:
|
||||
"""
|
||||
For each parent chunk in preview_texts, concurrently call generate_summary to generate a summary
|
||||
@@ -393,7 +389,6 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
|
||||
tenant_id=tenant_id,
|
||||
text=preview.content,
|
||||
summary_index_setting=summary_index_setting,
|
||||
document_language=doc_language,
|
||||
)
|
||||
preview.summary = summary
|
||||
else:
|
||||
@@ -402,7 +397,6 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
|
||||
tenant_id=tenant_id,
|
||||
text=preview.content,
|
||||
summary_index_setting=summary_index_setting,
|
||||
document_language=doc_language,
|
||||
)
|
||||
preview.summary = summary
|
||||
|
||||
|
||||
@@ -241,11 +241,7 @@ class QAIndexProcessor(BaseIndexProcessor):
|
||||
}
|
||||
|
||||
def generate_summary_preview(
|
||||
self,
|
||||
tenant_id: str,
|
||||
preview_texts: list[PreviewDetail],
|
||||
summary_index_setting: dict,
|
||||
doc_language: str | None = None,
|
||||
self, tenant_id: str, preview_texts: list[PreviewDetail], summary_index_setting: dict
|
||||
) -> list[PreviewDetail]:
|
||||
"""
|
||||
QA model doesn't generate summaries, so this method returns preview_texts unchanged.
|
||||
|
||||
@@ -192,33 +192,32 @@ class AgentNode(Node[AgentNodeData]):
|
||||
result[parameter_name] = None
|
||||
continue
|
||||
agent_input = node_data.agent_parameters[parameter_name]
|
||||
match agent_input.type:
|
||||
case "variable":
|
||||
variable = variable_pool.get(agent_input.value) # type: ignore
|
||||
if variable is None:
|
||||
raise AgentVariableNotFoundError(str(agent_input.value))
|
||||
parameter_value = variable.value
|
||||
case "mixed" | "constant":
|
||||
# variable_pool.convert_template expects a string template,
|
||||
# but if passing a dict, convert to JSON string first before rendering
|
||||
try:
|
||||
if not isinstance(agent_input.value, str):
|
||||
parameter_value = json.dumps(agent_input.value, ensure_ascii=False)
|
||||
else:
|
||||
parameter_value = str(agent_input.value)
|
||||
except TypeError:
|
||||
if agent_input.type == "variable":
|
||||
variable = variable_pool.get(agent_input.value) # type: ignore
|
||||
if variable is None:
|
||||
raise AgentVariableNotFoundError(str(agent_input.value))
|
||||
parameter_value = variable.value
|
||||
elif agent_input.type in {"mixed", "constant"}:
|
||||
# variable_pool.convert_template expects a string template,
|
||||
# but if passing a dict, convert to JSON string first before rendering
|
||||
try:
|
||||
if not isinstance(agent_input.value, str):
|
||||
parameter_value = json.dumps(agent_input.value, ensure_ascii=False)
|
||||
else:
|
||||
parameter_value = str(agent_input.value)
|
||||
segment_group = variable_pool.convert_template(parameter_value)
|
||||
parameter_value = segment_group.log if for_log else segment_group.text
|
||||
# variable_pool.convert_template returns a string,
|
||||
# so we need to convert it back to a dictionary
|
||||
try:
|
||||
if not isinstance(agent_input.value, str):
|
||||
parameter_value = json.loads(parameter_value)
|
||||
except json.JSONDecodeError:
|
||||
parameter_value = parameter_value
|
||||
case _:
|
||||
raise AgentInputTypeError(agent_input.type)
|
||||
except TypeError:
|
||||
parameter_value = str(agent_input.value)
|
||||
segment_group = variable_pool.convert_template(parameter_value)
|
||||
parameter_value = segment_group.log if for_log else segment_group.text
|
||||
# variable_pool.convert_template returns a string,
|
||||
# so we need to convert it back to a dictionary
|
||||
try:
|
||||
if not isinstance(agent_input.value, str):
|
||||
parameter_value = json.loads(parameter_value)
|
||||
except json.JSONDecodeError:
|
||||
parameter_value = parameter_value
|
||||
else:
|
||||
raise AgentInputTypeError(agent_input.type)
|
||||
value = parameter_value
|
||||
if parameter.type == "array[tools]":
|
||||
value = cast(list[dict[str, Any]], value)
|
||||
@@ -375,13 +374,12 @@ class AgentNode(Node[AgentNodeData]):
|
||||
result: dict[str, Any] = {}
|
||||
for parameter_name in typed_node_data.agent_parameters:
|
||||
input = typed_node_data.agent_parameters[parameter_name]
|
||||
match input.type:
|
||||
case "mixed" | "constant":
|
||||
selectors = VariableTemplateParser(str(input.value)).extract_variable_selectors()
|
||||
for selector in selectors:
|
||||
result[selector.variable] = selector.value_selector
|
||||
case "variable":
|
||||
result[parameter_name] = input.value
|
||||
if input.type in ["mixed", "constant"]:
|
||||
selectors = VariableTemplateParser(str(input.value)).extract_variable_selectors()
|
||||
for selector in selectors:
|
||||
result[selector.variable] = selector.value_selector
|
||||
elif input.type == "variable":
|
||||
result[parameter_name] = input.value
|
||||
|
||||
result = {node_id + "." + key: value for key, value in result.items()}
|
||||
|
||||
|
||||
@@ -270,18 +270,15 @@ class DatasourceNode(Node[DatasourceNodeData]):
|
||||
if typed_node_data.datasource_parameters:
|
||||
for parameter_name in typed_node_data.datasource_parameters:
|
||||
input = typed_node_data.datasource_parameters[parameter_name]
|
||||
match input.type:
|
||||
case "mixed":
|
||||
assert isinstance(input.value, str)
|
||||
selectors = VariableTemplateParser(input.value).extract_variable_selectors()
|
||||
for selector in selectors:
|
||||
result[selector.variable] = selector.value_selector
|
||||
case "variable":
|
||||
result[parameter_name] = input.value
|
||||
case "constant":
|
||||
pass
|
||||
case None:
|
||||
pass
|
||||
if input.type == "mixed":
|
||||
assert isinstance(input.value, str)
|
||||
selectors = VariableTemplateParser(input.value).extract_variable_selectors()
|
||||
for selector in selectors:
|
||||
result[selector.variable] = selector.value_selector
|
||||
elif input.type == "variable":
|
||||
result[parameter_name] = input.value
|
||||
elif input.type == "constant":
|
||||
pass
|
||||
|
||||
result = {node_id + "." + key: value for key, value in result.items()}
|
||||
|
||||
@@ -311,107 +308,99 @@ class DatasourceNode(Node[DatasourceNodeData]):
|
||||
variables: dict[str, Any] = {}
|
||||
|
||||
for message in message_stream:
|
||||
match message.type:
|
||||
case (
|
||||
DatasourceMessage.MessageType.IMAGE_LINK
|
||||
| DatasourceMessage.MessageType.BINARY_LINK
|
||||
| DatasourceMessage.MessageType.IMAGE
|
||||
):
|
||||
assert isinstance(message.message, DatasourceMessage.TextMessage)
|
||||
if message.type in {
|
||||
DatasourceMessage.MessageType.IMAGE_LINK,
|
||||
DatasourceMessage.MessageType.BINARY_LINK,
|
||||
DatasourceMessage.MessageType.IMAGE,
|
||||
}:
|
||||
assert isinstance(message.message, DatasourceMessage.TextMessage)
|
||||
|
||||
url = message.message.text
|
||||
transfer_method = FileTransferMethod.TOOL_FILE
|
||||
url = message.message.text
|
||||
transfer_method = FileTransferMethod.TOOL_FILE
|
||||
|
||||
datasource_file_id = str(url).split("/")[-1].split(".")[0]
|
||||
datasource_file_id = str(url).split("/")[-1].split(".")[0]
|
||||
|
||||
with Session(db.engine) as session:
|
||||
stmt = select(ToolFile).where(ToolFile.id == datasource_file_id)
|
||||
datasource_file = session.scalar(stmt)
|
||||
if datasource_file is None:
|
||||
raise ToolFileError(f"Tool file {datasource_file_id} does not exist")
|
||||
with Session(db.engine) as session:
|
||||
stmt = select(ToolFile).where(ToolFile.id == datasource_file_id)
|
||||
datasource_file = session.scalar(stmt)
|
||||
if datasource_file is None:
|
||||
raise ToolFileError(f"Tool file {datasource_file_id} does not exist")
|
||||
|
||||
mapping = {
|
||||
"tool_file_id": datasource_file_id,
|
||||
"type": file_factory.get_file_type_by_mime_type(datasource_file.mimetype),
|
||||
"transfer_method": transfer_method,
|
||||
"url": url,
|
||||
}
|
||||
file = file_factory.build_from_mapping(
|
||||
mapping = {
|
||||
"tool_file_id": datasource_file_id,
|
||||
"type": file_factory.get_file_type_by_mime_type(datasource_file.mimetype),
|
||||
"transfer_method": transfer_method,
|
||||
"url": url,
|
||||
}
|
||||
file = file_factory.build_from_mapping(
|
||||
mapping=mapping,
|
||||
tenant_id=self.tenant_id,
|
||||
)
|
||||
files.append(file)
|
||||
elif message.type == DatasourceMessage.MessageType.BLOB:
|
||||
# get tool file id
|
||||
assert isinstance(message.message, DatasourceMessage.TextMessage)
|
||||
assert message.meta
|
||||
|
||||
datasource_file_id = message.message.text.split("/")[-1].split(".")[0]
|
||||
with Session(db.engine) as session:
|
||||
stmt = select(ToolFile).where(ToolFile.id == datasource_file_id)
|
||||
datasource_file = session.scalar(stmt)
|
||||
if datasource_file is None:
|
||||
raise ToolFileError(f"datasource file {datasource_file_id} not exists")
|
||||
|
||||
mapping = {
|
||||
"tool_file_id": datasource_file_id,
|
||||
"transfer_method": FileTransferMethod.TOOL_FILE,
|
||||
}
|
||||
|
||||
files.append(
|
||||
file_factory.build_from_mapping(
|
||||
mapping=mapping,
|
||||
tenant_id=self.tenant_id,
|
||||
)
|
||||
files.append(file)
|
||||
case DatasourceMessage.MessageType.BLOB:
|
||||
# get tool file id
|
||||
assert isinstance(message.message, DatasourceMessage.TextMessage)
|
||||
assert message.meta
|
||||
)
|
||||
elif message.type == DatasourceMessage.MessageType.TEXT:
|
||||
assert isinstance(message.message, DatasourceMessage.TextMessage)
|
||||
text += message.message.text
|
||||
yield StreamChunkEvent(
|
||||
selector=[self._node_id, "text"],
|
||||
chunk=message.message.text,
|
||||
is_final=False,
|
||||
)
|
||||
elif message.type == DatasourceMessage.MessageType.JSON:
|
||||
assert isinstance(message.message, DatasourceMessage.JsonMessage)
|
||||
json.append(message.message.json_object)
|
||||
elif message.type == DatasourceMessage.MessageType.LINK:
|
||||
assert isinstance(message.message, DatasourceMessage.TextMessage)
|
||||
stream_text = f"Link: {message.message.text}\n"
|
||||
text += stream_text
|
||||
yield StreamChunkEvent(
|
||||
selector=[self._node_id, "text"],
|
||||
chunk=stream_text,
|
||||
is_final=False,
|
||||
)
|
||||
elif message.type == DatasourceMessage.MessageType.VARIABLE:
|
||||
assert isinstance(message.message, DatasourceMessage.VariableMessage)
|
||||
variable_name = message.message.variable_name
|
||||
variable_value = message.message.variable_value
|
||||
if message.message.stream:
|
||||
if not isinstance(variable_value, str):
|
||||
raise ValueError("When 'stream' is True, 'variable_value' must be a string.")
|
||||
if variable_name not in variables:
|
||||
variables[variable_name] = ""
|
||||
variables[variable_name] += variable_value
|
||||
|
||||
datasource_file_id = message.message.text.split("/")[-1].split(".")[0]
|
||||
with Session(db.engine) as session:
|
||||
stmt = select(ToolFile).where(ToolFile.id == datasource_file_id)
|
||||
datasource_file = session.scalar(stmt)
|
||||
if datasource_file is None:
|
||||
raise ToolFileError(f"datasource file {datasource_file_id} not exists")
|
||||
|
||||
mapping = {
|
||||
"tool_file_id": datasource_file_id,
|
||||
"transfer_method": FileTransferMethod.TOOL_FILE,
|
||||
}
|
||||
|
||||
files.append(
|
||||
file_factory.build_from_mapping(
|
||||
mapping=mapping,
|
||||
tenant_id=self.tenant_id,
|
||||
)
|
||||
)
|
||||
case DatasourceMessage.MessageType.TEXT:
|
||||
assert isinstance(message.message, DatasourceMessage.TextMessage)
|
||||
text += message.message.text
|
||||
yield StreamChunkEvent(
|
||||
selector=[self._node_id, "text"],
|
||||
chunk=message.message.text,
|
||||
selector=[self._node_id, variable_name],
|
||||
chunk=variable_value,
|
||||
is_final=False,
|
||||
)
|
||||
case DatasourceMessage.MessageType.JSON:
|
||||
assert isinstance(message.message, DatasourceMessage.JsonMessage)
|
||||
json.append(message.message.json_object)
|
||||
case DatasourceMessage.MessageType.LINK:
|
||||
assert isinstance(message.message, DatasourceMessage.TextMessage)
|
||||
stream_text = f"Link: {message.message.text}\n"
|
||||
text += stream_text
|
||||
yield StreamChunkEvent(
|
||||
selector=[self._node_id, "text"],
|
||||
chunk=stream_text,
|
||||
is_final=False,
|
||||
)
|
||||
case DatasourceMessage.MessageType.VARIABLE:
|
||||
assert isinstance(message.message, DatasourceMessage.VariableMessage)
|
||||
variable_name = message.message.variable_name
|
||||
variable_value = message.message.variable_value
|
||||
if message.message.stream:
|
||||
if not isinstance(variable_value, str):
|
||||
raise ValueError("When 'stream' is True, 'variable_value' must be a string.")
|
||||
if variable_name not in variables:
|
||||
variables[variable_name] = ""
|
||||
variables[variable_name] += variable_value
|
||||
|
||||
yield StreamChunkEvent(
|
||||
selector=[self._node_id, variable_name],
|
||||
chunk=variable_value,
|
||||
is_final=False,
|
||||
)
|
||||
else:
|
||||
variables[variable_name] = variable_value
|
||||
case DatasourceMessage.MessageType.FILE:
|
||||
assert message.meta is not None
|
||||
files.append(message.meta["file"])
|
||||
case (
|
||||
DatasourceMessage.MessageType.BLOB_CHUNK
|
||||
| DatasourceMessage.MessageType.LOG
|
||||
| DatasourceMessage.MessageType.RETRIEVER_RESOURCES
|
||||
):
|
||||
pass
|
||||
|
||||
else:
|
||||
variables[variable_name] = variable_value
|
||||
elif message.type == DatasourceMessage.MessageType.FILE:
|
||||
assert message.meta is not None
|
||||
files.append(message.meta["file"])
|
||||
# mark the end of the stream
|
||||
yield StreamChunkEvent(
|
||||
selector=[self._node_id, "text"],
|
||||
|
||||
@@ -78,21 +78,12 @@ class KnowledgeIndexNode(Node[KnowledgeIndexNodeData]):
|
||||
indexing_technique = node_data.indexing_technique or dataset.indexing_technique
|
||||
summary_index_setting = node_data.summary_index_setting or dataset.summary_index_setting
|
||||
|
||||
# Try to get document language if document_id is available
|
||||
doc_language = None
|
||||
document_id = variable_pool.get(["sys", SystemVariableKey.DOCUMENT_ID])
|
||||
if document_id:
|
||||
document = db.session.query(Document).filter_by(id=document_id.value).first()
|
||||
if document and document.doc_language:
|
||||
doc_language = document.doc_language
|
||||
|
||||
outputs = self._get_preview_output_with_summaries(
|
||||
node_data.chunk_structure,
|
||||
chunks,
|
||||
dataset=dataset,
|
||||
indexing_technique=indexing_technique,
|
||||
summary_index_setting=summary_index_setting,
|
||||
doc_language=doc_language,
|
||||
)
|
||||
return NodeRunResult(
|
||||
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
||||
@@ -324,7 +315,6 @@ class KnowledgeIndexNode(Node[KnowledgeIndexNodeData]):
|
||||
dataset: Dataset,
|
||||
indexing_technique: str | None = None,
|
||||
summary_index_setting: dict | None = None,
|
||||
doc_language: str | None = None,
|
||||
) -> Mapping[str, Any]:
|
||||
"""
|
||||
Generate preview output with summaries for chunks in preview mode.
|
||||
@@ -336,7 +326,6 @@ class KnowledgeIndexNode(Node[KnowledgeIndexNodeData]):
|
||||
dataset: Dataset object (for tenant_id)
|
||||
indexing_technique: Indexing technique from node config or dataset
|
||||
summary_index_setting: Summary index setting from node config or dataset
|
||||
doc_language: Optional document language to ensure summary is generated in the correct language
|
||||
"""
|
||||
index_processor = IndexProcessorFactory(chunk_structure).init_index_processor()
|
||||
preview_output = index_processor.format_preview(chunks)
|
||||
@@ -376,7 +365,6 @@ class KnowledgeIndexNode(Node[KnowledgeIndexNodeData]):
|
||||
tenant_id=dataset.tenant_id,
|
||||
text=preview_item["content"],
|
||||
summary_index_setting=summary_index_setting,
|
||||
document_language=doc_language,
|
||||
)
|
||||
if summary:
|
||||
preview_item["summary"] = summary
|
||||
@@ -386,7 +374,6 @@ class KnowledgeIndexNode(Node[KnowledgeIndexNodeData]):
|
||||
tenant_id=dataset.tenant_id,
|
||||
text=preview_item["content"],
|
||||
summary_index_setting=summary_index_setting,
|
||||
document_language=doc_language,
|
||||
)
|
||||
if summary:
|
||||
preview_item["summary"] = summary
|
||||
|
||||
@@ -303,34 +303,33 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
|
||||
elif str(node_data.retrieval_mode) == DatasetRetrieveConfigEntity.RetrieveStrategy.MULTIPLE:
|
||||
if node_data.multiple_retrieval_config is None:
|
||||
raise ValueError("multiple_retrieval_config is required")
|
||||
match node_data.multiple_retrieval_config.reranking_mode:
|
||||
case "reranking_model":
|
||||
if node_data.multiple_retrieval_config.reranking_model:
|
||||
reranking_model = {
|
||||
"reranking_provider_name": node_data.multiple_retrieval_config.reranking_model.provider,
|
||||
"reranking_model_name": node_data.multiple_retrieval_config.reranking_model.model,
|
||||
}
|
||||
else:
|
||||
reranking_model = None
|
||||
weights = None
|
||||
case "weighted_score":
|
||||
if node_data.multiple_retrieval_config.weights is None:
|
||||
raise ValueError("weights is required")
|
||||
reranking_model = None
|
||||
vector_setting = node_data.multiple_retrieval_config.weights.vector_setting
|
||||
weights = {
|
||||
"vector_setting": {
|
||||
"vector_weight": vector_setting.vector_weight,
|
||||
"embedding_provider_name": vector_setting.embedding_provider_name,
|
||||
"embedding_model_name": vector_setting.embedding_model_name,
|
||||
},
|
||||
"keyword_setting": {
|
||||
"keyword_weight": node_data.multiple_retrieval_config.weights.keyword_setting.keyword_weight
|
||||
},
|
||||
if node_data.multiple_retrieval_config.reranking_mode == "reranking_model":
|
||||
if node_data.multiple_retrieval_config.reranking_model:
|
||||
reranking_model = {
|
||||
"reranking_provider_name": node_data.multiple_retrieval_config.reranking_model.provider,
|
||||
"reranking_model_name": node_data.multiple_retrieval_config.reranking_model.model,
|
||||
}
|
||||
case _:
|
||||
else:
|
||||
reranking_model = None
|
||||
weights = None
|
||||
weights = None
|
||||
elif node_data.multiple_retrieval_config.reranking_mode == "weighted_score":
|
||||
if node_data.multiple_retrieval_config.weights is None:
|
||||
raise ValueError("weights is required")
|
||||
reranking_model = None
|
||||
vector_setting = node_data.multiple_retrieval_config.weights.vector_setting
|
||||
weights = {
|
||||
"vector_setting": {
|
||||
"vector_weight": vector_setting.vector_weight,
|
||||
"embedding_provider_name": vector_setting.embedding_provider_name,
|
||||
"embedding_model_name": vector_setting.embedding_model_name,
|
||||
},
|
||||
"keyword_setting": {
|
||||
"keyword_weight": node_data.multiple_retrieval_config.weights.keyword_setting.keyword_weight
|
||||
},
|
||||
}
|
||||
else:
|
||||
reranking_model = None
|
||||
weights = None
|
||||
all_documents = dataset_retrieval.multiple_retrieve(
|
||||
app_id=self.app_id,
|
||||
tenant_id=self.tenant_id,
|
||||
@@ -454,74 +453,73 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
|
||||
)
|
||||
filters: list[Any] = []
|
||||
metadata_condition = None
|
||||
match node_data.metadata_filtering_mode:
|
||||
case "disabled":
|
||||
return None, None, usage
|
||||
case "automatic":
|
||||
automatic_metadata_filters, automatic_usage = self._automatic_metadata_filter_func(
|
||||
dataset_ids, query, node_data
|
||||
if node_data.metadata_filtering_mode == "disabled":
|
||||
return None, None, usage
|
||||
elif node_data.metadata_filtering_mode == "automatic":
|
||||
automatic_metadata_filters, automatic_usage = self._automatic_metadata_filter_func(
|
||||
dataset_ids, query, node_data
|
||||
)
|
||||
usage = self._merge_usage(usage, automatic_usage)
|
||||
if automatic_metadata_filters:
|
||||
conditions = []
|
||||
for sequence, filter in enumerate(automatic_metadata_filters):
|
||||
DatasetRetrieval.process_metadata_filter_func(
|
||||
sequence,
|
||||
filter.get("condition", ""),
|
||||
filter.get("metadata_name", ""),
|
||||
filter.get("value"),
|
||||
filters,
|
||||
)
|
||||
conditions.append(
|
||||
Condition(
|
||||
name=filter.get("metadata_name"), # type: ignore
|
||||
comparison_operator=filter.get("condition"), # type: ignore
|
||||
value=filter.get("value"),
|
||||
)
|
||||
)
|
||||
metadata_condition = MetadataCondition(
|
||||
logical_operator=node_data.metadata_filtering_conditions.logical_operator
|
||||
if node_data.metadata_filtering_conditions
|
||||
else "or",
|
||||
conditions=conditions,
|
||||
)
|
||||
usage = self._merge_usage(usage, automatic_usage)
|
||||
if automatic_metadata_filters:
|
||||
conditions = []
|
||||
for sequence, filter in enumerate(automatic_metadata_filters):
|
||||
DatasetRetrieval.process_metadata_filter_func(
|
||||
sequence,
|
||||
filter.get("condition", ""),
|
||||
filter.get("metadata_name", ""),
|
||||
filter.get("value"),
|
||||
filters,
|
||||
elif node_data.metadata_filtering_mode == "manual":
|
||||
if node_data.metadata_filtering_conditions:
|
||||
conditions = []
|
||||
for sequence, condition in enumerate(node_data.metadata_filtering_conditions.conditions): # type: ignore
|
||||
metadata_name = condition.name
|
||||
expected_value = condition.value
|
||||
if expected_value is not None and condition.comparison_operator not in ("empty", "not empty"):
|
||||
if isinstance(expected_value, str):
|
||||
expected_value = self.graph_runtime_state.variable_pool.convert_template(
|
||||
expected_value
|
||||
).value[0]
|
||||
if expected_value.value_type in {"number", "integer", "float"}:
|
||||
expected_value = expected_value.value
|
||||
elif expected_value.value_type == "string":
|
||||
expected_value = re.sub(r"[\r\n\t]+", " ", expected_value.text).strip()
|
||||
else:
|
||||
raise ValueError("Invalid expected metadata value type")
|
||||
conditions.append(
|
||||
Condition(
|
||||
name=metadata_name,
|
||||
comparison_operator=condition.comparison_operator,
|
||||
value=expected_value,
|
||||
)
|
||||
conditions.append(
|
||||
Condition(
|
||||
name=filter.get("metadata_name"), # type: ignore
|
||||
comparison_operator=filter.get("condition"), # type: ignore
|
||||
value=filter.get("value"),
|
||||
)
|
||||
)
|
||||
metadata_condition = MetadataCondition(
|
||||
logical_operator=node_data.metadata_filtering_conditions.logical_operator
|
||||
if node_data.metadata_filtering_conditions
|
||||
else "or",
|
||||
conditions=conditions,
|
||||
)
|
||||
case "manual":
|
||||
if node_data.metadata_filtering_conditions:
|
||||
conditions = []
|
||||
for sequence, condition in enumerate(node_data.metadata_filtering_conditions.conditions): # type: ignore
|
||||
metadata_name = condition.name
|
||||
expected_value = condition.value
|
||||
if expected_value is not None and condition.comparison_operator not in ("empty", "not empty"):
|
||||
if isinstance(expected_value, str):
|
||||
expected_value = self.graph_runtime_state.variable_pool.convert_template(
|
||||
expected_value
|
||||
).value[0]
|
||||
if expected_value.value_type in {"number", "integer", "float"}:
|
||||
expected_value = expected_value.value
|
||||
elif expected_value.value_type == "string":
|
||||
expected_value = re.sub(r"[\r\n\t]+", " ", expected_value.text).strip()
|
||||
else:
|
||||
raise ValueError("Invalid expected metadata value type")
|
||||
conditions.append(
|
||||
Condition(
|
||||
name=metadata_name,
|
||||
comparison_operator=condition.comparison_operator,
|
||||
value=expected_value,
|
||||
)
|
||||
)
|
||||
filters = DatasetRetrieval.process_metadata_filter_func(
|
||||
sequence,
|
||||
condition.comparison_operator,
|
||||
metadata_name,
|
||||
expected_value,
|
||||
filters,
|
||||
)
|
||||
metadata_condition = MetadataCondition(
|
||||
logical_operator=node_data.metadata_filtering_conditions.logical_operator,
|
||||
conditions=conditions,
|
||||
filters = DatasetRetrieval.process_metadata_filter_func(
|
||||
sequence,
|
||||
condition.comparison_operator,
|
||||
metadata_name,
|
||||
expected_value,
|
||||
filters,
|
||||
)
|
||||
case _:
|
||||
raise ValueError("Invalid metadata filtering mode")
|
||||
metadata_condition = MetadataCondition(
|
||||
logical_operator=node_data.metadata_filtering_conditions.logical_operator,
|
||||
conditions=conditions,
|
||||
)
|
||||
else:
|
||||
raise ValueError("Invalid metadata filtering mode")
|
||||
if filters:
|
||||
if (
|
||||
node_data.metadata_filtering_conditions
|
||||
|
||||
@@ -482,17 +482,16 @@ class ToolNode(Node[ToolNodeData]):
|
||||
result = {}
|
||||
for parameter_name in typed_node_data.tool_parameters:
|
||||
input = typed_node_data.tool_parameters[parameter_name]
|
||||
match input.type:
|
||||
case "mixed":
|
||||
assert isinstance(input.value, str)
|
||||
selectors = VariableTemplateParser(input.value).extract_variable_selectors()
|
||||
for selector in selectors:
|
||||
result[selector.variable] = selector.value_selector
|
||||
case "variable":
|
||||
selector_key = ".".join(input.value)
|
||||
result[f"#{selector_key}#"] = input.value
|
||||
case "constant":
|
||||
pass
|
||||
if input.type == "mixed":
|
||||
assert isinstance(input.value, str)
|
||||
selectors = VariableTemplateParser(input.value).extract_variable_selectors()
|
||||
for selector in selectors:
|
||||
result[selector.variable] = selector.value_selector
|
||||
elif input.type == "variable":
|
||||
selector_key = ".".join(input.value)
|
||||
result[f"#{selector_key}#"] = input.value
|
||||
elif input.type == "constant":
|
||||
pass
|
||||
|
||||
result = {node_id + "." + key: value for key, value in result.items()}
|
||||
|
||||
|
||||
@@ -390,7 +390,8 @@ class ClickZettaVolumeStorage(BaseStorage):
|
||||
"""
|
||||
content = self.load_once(filename)
|
||||
|
||||
Path(target_filepath).write_bytes(content)
|
||||
with Path(target_filepath).open("wb") as f:
|
||||
f.write(content)
|
||||
|
||||
logger.debug("File %s downloaded from ClickZetta Volume to %s", filename, target_filepath)
|
||||
|
||||
|
||||
@@ -1,69 +1,36 @@
|
||||
from __future__ import annotations
|
||||
from flask_restx import Namespace, fields
|
||||
|
||||
from datetime import datetime
|
||||
from libs.helper import TimestampField
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
annotation_fields = {
|
||||
"id": fields.String,
|
||||
"question": fields.String,
|
||||
"answer": fields.Raw(attribute="content"),
|
||||
"hit_count": fields.Integer,
|
||||
"created_at": TimestampField,
|
||||
# 'account': fields.Nested(simple_account_fields, allow_null=True)
|
||||
}
|
||||
|
||||
|
||||
def _to_timestamp(value: datetime | int | None) -> int | None:
|
||||
if isinstance(value, datetime):
|
||||
return int(value.timestamp())
|
||||
return value
|
||||
def build_annotation_model(api_or_ns: Namespace):
|
||||
"""Build the annotation model for the API or Namespace."""
|
||||
return api_or_ns.model("Annotation", annotation_fields)
|
||||
|
||||
|
||||
class ResponseModel(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
extra="ignore",
|
||||
populate_by_name=True,
|
||||
serialize_by_alias=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
annotation_list_fields = {
|
||||
"data": fields.List(fields.Nested(annotation_fields)),
|
||||
}
|
||||
|
||||
annotation_hit_history_fields = {
|
||||
"id": fields.String,
|
||||
"source": fields.String,
|
||||
"score": fields.Float,
|
||||
"question": fields.String,
|
||||
"created_at": TimestampField,
|
||||
"match": fields.String(attribute="annotation_question"),
|
||||
"response": fields.String(attribute="annotation_content"),
|
||||
}
|
||||
|
||||
class Annotation(ResponseModel):
|
||||
id: str
|
||||
question: str | None = None
|
||||
answer: str | None = Field(default=None, validation_alias="content")
|
||||
hit_count: int | None = None
|
||||
created_at: int | None = None
|
||||
|
||||
@field_validator("created_at", mode="before")
|
||||
@classmethod
|
||||
def _normalize_created_at(cls, value: datetime | int | None) -> int | None:
|
||||
return _to_timestamp(value)
|
||||
|
||||
|
||||
class AnnotationList(ResponseModel):
|
||||
data: list[Annotation]
|
||||
has_more: bool
|
||||
limit: int
|
||||
total: int
|
||||
page: int
|
||||
|
||||
|
||||
class AnnotationExportList(ResponseModel):
|
||||
data: list[Annotation]
|
||||
|
||||
|
||||
class AnnotationHitHistory(ResponseModel):
|
||||
id: str
|
||||
source: str | None = None
|
||||
score: float | None = None
|
||||
question: str | None = None
|
||||
created_at: int | None = None
|
||||
match: str | None = Field(default=None, validation_alias="annotation_question")
|
||||
response: str | None = Field(default=None, validation_alias="annotation_content")
|
||||
|
||||
@field_validator("created_at", mode="before")
|
||||
@classmethod
|
||||
def _normalize_created_at(cls, value: datetime | int | None) -> int | None:
|
||||
return _to_timestamp(value)
|
||||
|
||||
|
||||
class AnnotationHitHistoryList(ResponseModel):
|
||||
data: list[AnnotationHitHistory]
|
||||
has_more: bool
|
||||
limit: int
|
||||
total: int
|
||||
page: int
|
||||
annotation_hit_history_list_fields = {
|
||||
"data": fields.List(fields.Nested(annotation_hit_history_fields)),
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask_restx import fields
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from flask_restx import Namespace, fields
|
||||
|
||||
simple_end_user_fields = {
|
||||
"id": fields.String,
|
||||
@@ -11,18 +8,5 @@ simple_end_user_fields = {
|
||||
}
|
||||
|
||||
|
||||
class ResponseModel(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
extra="ignore",
|
||||
populate_by_name=True,
|
||||
serialize_by_alias=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
class SimpleEndUser(ResponseModel):
|
||||
id: str
|
||||
type: str
|
||||
is_anonymous: bool
|
||||
session_id: str | None = None
|
||||
def build_simple_end_user_model(api_or_ns: Namespace):
|
||||
return api_or_ns.model("SimpleEndUser", simple_end_user_fields)
|
||||
|
||||
+31
-78
@@ -1,11 +1,6 @@
|
||||
from __future__ import annotations
|
||||
from flask_restx import Namespace, fields
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask_restx import fields
|
||||
from pydantic import BaseModel, ConfigDict, computed_field, field_validator
|
||||
|
||||
from core.file import helpers as file_helpers
|
||||
from libs.helper import AvatarUrlField, TimestampField
|
||||
|
||||
simple_account_fields = {
|
||||
"id": fields.String,
|
||||
@@ -14,78 +9,36 @@ simple_account_fields = {
|
||||
}
|
||||
|
||||
|
||||
def _to_timestamp(value: datetime | int | None) -> int | None:
|
||||
if isinstance(value, datetime):
|
||||
return int(value.timestamp())
|
||||
return value
|
||||
def build_simple_account_model(api_or_ns: Namespace):
|
||||
return api_or_ns.model("SimpleAccount", simple_account_fields)
|
||||
|
||||
|
||||
def _build_avatar_url(avatar: str | None) -> str | None:
|
||||
if avatar is None:
|
||||
return None
|
||||
if avatar.startswith(("http://", "https://")):
|
||||
return avatar
|
||||
return file_helpers.get_signed_file_url(avatar)
|
||||
account_fields = {
|
||||
"id": fields.String,
|
||||
"name": fields.String,
|
||||
"avatar": fields.String,
|
||||
"avatar_url": AvatarUrlField,
|
||||
"email": fields.String,
|
||||
"is_password_set": fields.Boolean,
|
||||
"interface_language": fields.String,
|
||||
"interface_theme": fields.String,
|
||||
"timezone": fields.String,
|
||||
"last_login_at": TimestampField,
|
||||
"last_login_ip": fields.String,
|
||||
"created_at": TimestampField,
|
||||
}
|
||||
|
||||
account_with_role_fields = {
|
||||
"id": fields.String,
|
||||
"name": fields.String,
|
||||
"avatar": fields.String,
|
||||
"avatar_url": AvatarUrlField,
|
||||
"email": fields.String,
|
||||
"last_login_at": TimestampField,
|
||||
"last_active_at": TimestampField,
|
||||
"created_at": TimestampField,
|
||||
"role": fields.String,
|
||||
"status": fields.String,
|
||||
}
|
||||
|
||||
class ResponseModel(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
extra="ignore",
|
||||
populate_by_name=True,
|
||||
serialize_by_alias=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
class SimpleAccount(ResponseModel):
|
||||
id: str
|
||||
name: str
|
||||
email: str
|
||||
|
||||
|
||||
class _AccountAvatar(ResponseModel):
|
||||
avatar: str | None = None
|
||||
|
||||
@computed_field(return_type=str | None) # type: ignore[prop-decorator]
|
||||
@property
|
||||
def avatar_url(self) -> str | None:
|
||||
return _build_avatar_url(self.avatar)
|
||||
|
||||
|
||||
class Account(_AccountAvatar):
|
||||
id: str
|
||||
name: str
|
||||
email: str
|
||||
is_password_set: bool
|
||||
interface_language: str | None = None
|
||||
interface_theme: str | None = None
|
||||
timezone: str | None = None
|
||||
last_login_at: int | None = None
|
||||
last_login_ip: str | None = None
|
||||
created_at: int | None = None
|
||||
|
||||
@field_validator("last_login_at", "created_at", mode="before")
|
||||
@classmethod
|
||||
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||
return _to_timestamp(value)
|
||||
|
||||
|
||||
class AccountWithRole(_AccountAvatar):
|
||||
id: str
|
||||
name: str
|
||||
email: str
|
||||
last_login_at: int | None = None
|
||||
last_active_at: int | None = None
|
||||
created_at: int | None = None
|
||||
role: str
|
||||
status: str
|
||||
|
||||
@field_validator("last_login_at", "last_active_at", "created_at", mode="before")
|
||||
@classmethod
|
||||
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||
return _to_timestamp(value)
|
||||
|
||||
|
||||
class AccountWithRoleList(ResponseModel):
|
||||
accounts: list[AccountWithRole]
|
||||
account_with_role_list_fields = {"accounts": fields.List(fields.Nested(account_with_role_fields))}
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
from __future__ import annotations
|
||||
from flask_restx import Namespace, fields
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
dataset_tag_fields = {
|
||||
"id": fields.String,
|
||||
"name": fields.String,
|
||||
"type": fields.String,
|
||||
"binding_count": fields.String,
|
||||
}
|
||||
|
||||
|
||||
class ResponseModel(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
extra="ignore",
|
||||
populate_by_name=True,
|
||||
serialize_by_alias=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
class DataSetTag(ResponseModel):
|
||||
id: str
|
||||
name: str
|
||||
type: str
|
||||
binding_count: str | None = None
|
||||
def build_dataset_tag_fields(api_or_ns: Namespace):
|
||||
return api_or_ns.model("DataSetTag", dataset_tag_fields)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from flask_restx import Namespace, fields
|
||||
|
||||
from fields.end_user_fields import simple_end_user_fields
|
||||
from fields.member_fields import simple_account_fields
|
||||
from fields.end_user_fields import build_simple_end_user_model, simple_end_user_fields
|
||||
from fields.member_fields import build_simple_account_model, simple_account_fields
|
||||
from fields.workflow_run_fields import (
|
||||
build_workflow_run_for_archived_log_model,
|
||||
build_workflow_run_for_log_model,
|
||||
@@ -25,9 +25,17 @@ workflow_app_log_partial_fields = {
|
||||
def build_workflow_app_log_partial_model(api_or_ns: Namespace):
|
||||
"""Build the workflow app log partial model for the API or Namespace."""
|
||||
workflow_run_model = build_workflow_run_for_log_model(api_or_ns)
|
||||
simple_account_model = build_simple_account_model(api_or_ns)
|
||||
simple_end_user_model = build_simple_end_user_model(api_or_ns)
|
||||
|
||||
copied_fields = workflow_app_log_partial_fields.copy()
|
||||
copied_fields["workflow_run"] = fields.Nested(workflow_run_model, attribute="workflow_run", allow_null=True)
|
||||
copied_fields["created_by_account"] = fields.Nested(
|
||||
simple_account_model, attribute="created_by_account", allow_null=True
|
||||
)
|
||||
copied_fields["created_by_end_user"] = fields.Nested(
|
||||
simple_end_user_model, attribute="created_by_end_user", allow_null=True
|
||||
)
|
||||
return api_or_ns.model("WorkflowAppLogPartial", copied_fields)
|
||||
|
||||
|
||||
@@ -44,9 +52,17 @@ workflow_archived_log_partial_fields = {
|
||||
def build_workflow_archived_log_partial_model(api_or_ns: Namespace):
|
||||
"""Build the workflow archived log partial model for the API or Namespace."""
|
||||
workflow_run_model = build_workflow_run_for_archived_log_model(api_or_ns)
|
||||
simple_account_model = build_simple_account_model(api_or_ns)
|
||||
simple_end_user_model = build_simple_end_user_model(api_or_ns)
|
||||
|
||||
copied_fields = workflow_archived_log_partial_fields.copy()
|
||||
copied_fields["workflow_run"] = fields.Nested(workflow_run_model, allow_null=True)
|
||||
copied_fields["created_by_account"] = fields.Nested(
|
||||
simple_account_model, attribute="created_by_account", allow_null=True
|
||||
)
|
||||
copied_fields["created_by_end_user"] = fields.Nested(
|
||||
simple_end_user_model, attribute="created_by_end_user", allow_null=True
|
||||
)
|
||||
return api_or_ns.model("WorkflowArchivedLogPartial", copied_fields)
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.11.4"
|
||||
version = "1.12.0"
|
||||
requires-python = ">=3.11,<3.13"
|
||||
|
||||
dependencies = [
|
||||
@@ -145,7 +145,7 @@ dev = [
|
||||
"types-openpyxl~=3.1.5",
|
||||
"types-pexpect~=4.9.0",
|
||||
"types-protobuf~=5.29.1",
|
||||
"types-psutil~=7.2.2",
|
||||
"types-psutil~=7.0.0",
|
||||
"types-psycopg2~=2.9.21",
|
||||
"types-pygments~=2.19.0",
|
||||
"types-pymysql~=1.1.0",
|
||||
|
||||
@@ -158,7 +158,7 @@ class AppAnnotationService:
|
||||
.order_by(MessageAnnotation.created_at.desc(), MessageAnnotation.id.desc())
|
||||
)
|
||||
annotations = db.paginate(select=stmt, page=page, per_page=limit, max_per_page=100, error_out=False)
|
||||
return annotations.items, annotations.total or 0
|
||||
return annotations.items, annotations.total
|
||||
|
||||
@classmethod
|
||||
def export_annotation_list_by_app_id(cls, app_id: str):
|
||||
@@ -524,7 +524,7 @@ class AppAnnotationService:
|
||||
annotation_hit_histories = db.paginate(
|
||||
select=stmt, page=page, per_page=limit, max_per_page=100, error_out=False
|
||||
)
|
||||
return annotation_hit_histories.items, annotation_hit_histories.total or 0
|
||||
return annotation_hit_histories.items, annotation_hit_histories.total
|
||||
|
||||
@classmethod
|
||||
def get_annotation_by_id(cls, annotation_id: str) -> MessageAnnotation | None:
|
||||
|
||||
@@ -16,7 +16,6 @@ from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from configs import dify_config
|
||||
from core.db.session_factory import session_factory
|
||||
from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
|
||||
from core.file import helpers as file_helpers
|
||||
from core.helper.name_generator import generate_incremental_name
|
||||
@@ -1389,46 +1388,6 @@ class DocumentService:
|
||||
).all()
|
||||
return documents
|
||||
|
||||
@staticmethod
|
||||
def update_documents_need_summary(dataset_id: str, document_ids: Sequence[str], need_summary: bool = True) -> int:
|
||||
"""
|
||||
Update need_summary field for multiple documents.
|
||||
|
||||
This method handles the case where documents were created when summary_index_setting was disabled,
|
||||
and need to be updated when summary_index_setting is later enabled.
|
||||
|
||||
Args:
|
||||
dataset_id: Dataset ID
|
||||
document_ids: List of document IDs to update
|
||||
need_summary: Value to set for need_summary field (default: True)
|
||||
|
||||
Returns:
|
||||
Number of documents updated
|
||||
"""
|
||||
if not document_ids:
|
||||
return 0
|
||||
|
||||
document_id_list: list[str] = [str(document_id) for document_id in document_ids]
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
updated_count = (
|
||||
session.query(Document)
|
||||
.filter(
|
||||
Document.id.in_(document_id_list),
|
||||
Document.dataset_id == dataset_id,
|
||||
Document.doc_form != "qa_model", # Skip qa_model documents
|
||||
)
|
||||
.update({Document.need_summary: need_summary}, synchronize_session=False)
|
||||
)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"Updated need_summary to %s for %d documents in dataset %s",
|
||||
need_summary,
|
||||
updated_count,
|
||||
dataset_id,
|
||||
)
|
||||
return updated_count
|
||||
|
||||
@staticmethod
|
||||
def get_document_download_url(document: Document) -> str:
|
||||
"""
|
||||
@@ -2978,15 +2937,14 @@ class DocumentService:
|
||||
"""
|
||||
now = naive_utc_now()
|
||||
|
||||
match action:
|
||||
case "enable":
|
||||
return DocumentService._prepare_enable_update(document, now)
|
||||
case "disable":
|
||||
return DocumentService._prepare_disable_update(document, user, now)
|
||||
case "archive":
|
||||
return DocumentService._prepare_archive_update(document, user, now)
|
||||
case "un_archive":
|
||||
return DocumentService._prepare_unarchive_update(document, now)
|
||||
if action == "enable":
|
||||
return DocumentService._prepare_enable_update(document, now)
|
||||
elif action == "disable":
|
||||
return DocumentService._prepare_disable_update(document, user, now)
|
||||
elif action == "archive":
|
||||
return DocumentService._prepare_archive_update(document, user, now)
|
||||
elif action == "un_archive":
|
||||
return DocumentService._prepare_unarchive_update(document, now)
|
||||
|
||||
return None
|
||||
|
||||
@@ -3623,57 +3581,56 @@ class SegmentService:
|
||||
# Check if segment_ids is not empty to avoid WHERE false condition
|
||||
if not segment_ids or len(segment_ids) == 0:
|
||||
return
|
||||
match action:
|
||||
case "enable":
|
||||
segments = db.session.scalars(
|
||||
select(DocumentSegment).where(
|
||||
DocumentSegment.id.in_(segment_ids),
|
||||
DocumentSegment.dataset_id == dataset.id,
|
||||
DocumentSegment.document_id == document.id,
|
||||
DocumentSegment.enabled == False,
|
||||
)
|
||||
).all()
|
||||
if not segments:
|
||||
return
|
||||
real_deal_segment_ids = []
|
||||
for segment in segments:
|
||||
indexing_cache_key = f"segment_{segment.id}_indexing"
|
||||
cache_result = redis_client.get(indexing_cache_key)
|
||||
if cache_result is not None:
|
||||
continue
|
||||
segment.enabled = True
|
||||
segment.disabled_at = None
|
||||
segment.disabled_by = None
|
||||
db.session.add(segment)
|
||||
real_deal_segment_ids.append(segment.id)
|
||||
db.session.commit()
|
||||
if action == "enable":
|
||||
segments = db.session.scalars(
|
||||
select(DocumentSegment).where(
|
||||
DocumentSegment.id.in_(segment_ids),
|
||||
DocumentSegment.dataset_id == dataset.id,
|
||||
DocumentSegment.document_id == document.id,
|
||||
DocumentSegment.enabled == False,
|
||||
)
|
||||
).all()
|
||||
if not segments:
|
||||
return
|
||||
real_deal_segment_ids = []
|
||||
for segment in segments:
|
||||
indexing_cache_key = f"segment_{segment.id}_indexing"
|
||||
cache_result = redis_client.get(indexing_cache_key)
|
||||
if cache_result is not None:
|
||||
continue
|
||||
segment.enabled = True
|
||||
segment.disabled_at = None
|
||||
segment.disabled_by = None
|
||||
db.session.add(segment)
|
||||
real_deal_segment_ids.append(segment.id)
|
||||
db.session.commit()
|
||||
|
||||
enable_segments_to_index_task.delay(real_deal_segment_ids, dataset.id, document.id)
|
||||
case "disable":
|
||||
segments = db.session.scalars(
|
||||
select(DocumentSegment).where(
|
||||
DocumentSegment.id.in_(segment_ids),
|
||||
DocumentSegment.dataset_id == dataset.id,
|
||||
DocumentSegment.document_id == document.id,
|
||||
DocumentSegment.enabled == True,
|
||||
)
|
||||
).all()
|
||||
if not segments:
|
||||
return
|
||||
real_deal_segment_ids = []
|
||||
for segment in segments:
|
||||
indexing_cache_key = f"segment_{segment.id}_indexing"
|
||||
cache_result = redis_client.get(indexing_cache_key)
|
||||
if cache_result is not None:
|
||||
continue
|
||||
segment.enabled = False
|
||||
segment.disabled_at = naive_utc_now()
|
||||
segment.disabled_by = current_user.id
|
||||
db.session.add(segment)
|
||||
real_deal_segment_ids.append(segment.id)
|
||||
db.session.commit()
|
||||
enable_segments_to_index_task.delay(real_deal_segment_ids, dataset.id, document.id)
|
||||
elif action == "disable":
|
||||
segments = db.session.scalars(
|
||||
select(DocumentSegment).where(
|
||||
DocumentSegment.id.in_(segment_ids),
|
||||
DocumentSegment.dataset_id == dataset.id,
|
||||
DocumentSegment.document_id == document.id,
|
||||
DocumentSegment.enabled == True,
|
||||
)
|
||||
).all()
|
||||
if not segments:
|
||||
return
|
||||
real_deal_segment_ids = []
|
||||
for segment in segments:
|
||||
indexing_cache_key = f"segment_{segment.id}_indexing"
|
||||
cache_result = redis_client.get(indexing_cache_key)
|
||||
if cache_result is not None:
|
||||
continue
|
||||
segment.enabled = False
|
||||
segment.disabled_at = naive_utc_now()
|
||||
segment.disabled_by = current_user.id
|
||||
db.session.add(segment)
|
||||
real_deal_segment_ids.append(segment.id)
|
||||
db.session.commit()
|
||||
|
||||
disable_segments_from_index_task.delay(real_deal_segment_ids, dataset.id, document.id)
|
||||
disable_segments_from_index_task.delay(real_deal_segment_ids, dataset.id, document.id)
|
||||
|
||||
@classmethod
|
||||
def create_child_chunk(
|
||||
|
||||
@@ -174,10 +174,6 @@ class RagPipelineTransformService:
|
||||
else:
|
||||
dataset.retrieval_model = knowledge_configuration.retrieval_model.model_dump()
|
||||
|
||||
# Copy summary_index_setting from dataset to knowledge_index node configuration
|
||||
if dataset.summary_index_setting:
|
||||
knowledge_configuration.summary_index_setting = dataset.summary_index_setting
|
||||
|
||||
knowledge_configuration_dict.update(knowledge_configuration.model_dump())
|
||||
node["data"] = knowledge_configuration_dict
|
||||
return node
|
||||
|
||||
@@ -49,18 +49,11 @@ class SummaryIndexService:
|
||||
# Use lazy import to avoid circular import
|
||||
from core.rag.index_processor.processor.paragraph_index_processor import ParagraphIndexProcessor
|
||||
|
||||
# Get document language to ensure summary is generated in the correct language
|
||||
# This is especially important for image-only chunks where text is empty or minimal
|
||||
document_language = None
|
||||
if segment.document and segment.document.doc_language:
|
||||
document_language = segment.document.doc_language
|
||||
|
||||
summary_content, usage = ParagraphIndexProcessor.generate_summary(
|
||||
tenant_id=dataset.tenant_id,
|
||||
text=segment.content,
|
||||
summary_index_setting=summary_index_setting,
|
||||
segment_id=segment.id,
|
||||
document_language=document_language,
|
||||
)
|
||||
|
||||
if not summary_content:
|
||||
@@ -565,9 +558,6 @@ class SummaryIndexService:
|
||||
)
|
||||
session.add(summary_record)
|
||||
|
||||
# Commit the batch created records
|
||||
session.commit()
|
||||
|
||||
@staticmethod
|
||||
def update_summary_record_error(
|
||||
segment: DocumentSegment,
|
||||
@@ -772,6 +762,7 @@ class SummaryIndexService:
|
||||
dataset=dataset,
|
||||
status="not_started",
|
||||
)
|
||||
session.commit() # Commit initial records
|
||||
|
||||
summary_records = []
|
||||
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import builtins
|
||||
import contextlib
|
||||
import importlib
|
||||
import sys
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask.views import MethodView
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
|
||||
from extensions import ext_fastopenapi
|
||||
from extensions.ext_database import db
|
||||
from services.feature_service import FeatureModel, SystemFeatureModel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""
|
||||
Creates a Flask application instance configured for testing.
|
||||
"""
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.config["SECRET_KEY"] = "test-secret"
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
||||
|
||||
# Initialize the database with the app
|
||||
db.init_app(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fix_method_view_issue(monkeypatch):
|
||||
"""
|
||||
Automatic fixture to patch 'builtins.MethodView'.
|
||||
|
||||
Why this is needed:
|
||||
The official legacy codebase contains a global patch in its initialization logic:
|
||||
if not hasattr(builtins, "MethodView"):
|
||||
builtins.MethodView = MethodView
|
||||
|
||||
Some dependencies (like ext_fastopenapi or older Flask extensions) might implicitly
|
||||
rely on 'MethodView' being available in the global builtins namespace.
|
||||
|
||||
Refactoring Note:
|
||||
While patching builtins is generally discouraged due to global side effects,
|
||||
this fixture reproduces the production environment's state to ensure tests are realistic.
|
||||
We use 'monkeypatch' to ensure that this change is undone after the test finishes,
|
||||
keeping other tests isolated.
|
||||
"""
|
||||
if not hasattr(builtins, "MethodView"):
|
||||
# 'raising=False' allows us to set an attribute that doesn't exist yet
|
||||
monkeypatch.setattr(builtins, "MethodView", MethodView, raising=False)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Helper Functions for Fixture Complexity Reduction
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_isolated_router():
|
||||
"""
|
||||
Creates a fresh, isolated router instance to prevent route pollution.
|
||||
"""
|
||||
import controllers.fastopenapi
|
||||
|
||||
# Dynamically get the class type (e.g., FlaskRouter) to avoid hardcoding dependencies
|
||||
RouterClass = type(controllers.fastopenapi.console_router)
|
||||
return RouterClass()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _patch_auth_and_router(temp_router):
|
||||
"""
|
||||
Context manager that applies all necessary patches for:
|
||||
1. The console_router (redirecting to our isolated temp_router)
|
||||
2. Authentication decorators (disabling them with no-ops)
|
||||
3. User/Account loaders (mocking authenticated state)
|
||||
"""
|
||||
|
||||
def noop(f):
|
||||
return f
|
||||
|
||||
# We patch the SOURCE of the decorators/functions, not the destination module.
|
||||
# This ensures that when 'controllers.console.feature' imports them, it gets the mocks.
|
||||
with (
|
||||
patch("controllers.fastopenapi.console_router", temp_router),
|
||||
patch("extensions.ext_fastopenapi.console_router", temp_router),
|
||||
patch("controllers.console.wraps.setup_required", side_effect=noop),
|
||||
patch("libs.login.login_required", side_effect=noop),
|
||||
patch("controllers.console.wraps.account_initialization_required", side_effect=noop),
|
||||
patch("controllers.console.wraps.cloud_utm_record", side_effect=noop),
|
||||
patch("libs.login.current_account_with_tenant", return_value=(MagicMock(), "tenant-id")),
|
||||
patch("libs.login.current_user", MagicMock(is_authenticated=True)),
|
||||
):
|
||||
# Explicitly reload ext_fastopenapi to ensure it uses the patched console_router
|
||||
import extensions.ext_fastopenapi
|
||||
|
||||
importlib.reload(extensions.ext_fastopenapi)
|
||||
|
||||
yield
|
||||
|
||||
|
||||
def _force_reload_module(target_module: str, alias_module: str):
|
||||
"""
|
||||
Forces a reload of the specified module and handles sys.modules aliasing.
|
||||
|
||||
Why reload?
|
||||
Python decorators (like @route, @login_required) run at IMPORT time.
|
||||
To apply our patches (mocks/no-ops) to these decorators, we must re-import
|
||||
the module while the patches are active.
|
||||
|
||||
Why alias?
|
||||
If 'ext_fastopenapi' imports the controller as 'api.controllers...', but we import
|
||||
it as 'controllers...', Python treats them as two separate modules. This causes:
|
||||
1. Double execution of decorators (registering routes twice -> AssertionError).
|
||||
2. Type mismatch errors (Class A from module X is not Class A from module Y).
|
||||
|
||||
This function ensures both names point to the SAME loaded module instance.
|
||||
"""
|
||||
# 1. Clean existing entries to force re-import
|
||||
if target_module in sys.modules:
|
||||
del sys.modules[target_module]
|
||||
if alias_module in sys.modules:
|
||||
del sys.modules[alias_module]
|
||||
|
||||
# 2. Import the module (triggering decorators with active patches)
|
||||
module = importlib.import_module(target_module)
|
||||
|
||||
# 3. Alias the module in sys.modules to prevent double loading
|
||||
sys.modules[alias_module] = sys.modules[target_module]
|
||||
|
||||
return module
|
||||
|
||||
|
||||
def _cleanup_modules(target_module: str, alias_module: str):
|
||||
"""
|
||||
Removes the module and its alias from sys.modules to prevent side effects
|
||||
on other tests.
|
||||
"""
|
||||
if target_module in sys.modules:
|
||||
del sys.modules[target_module]
|
||||
if alias_module in sys.modules:
|
||||
del sys.modules[alias_module]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_feature_module_env():
|
||||
"""
|
||||
Sets up a mocked environment for the feature module.
|
||||
|
||||
This fixture orchestrates:
|
||||
1. Creating an isolated router.
|
||||
2. Patching authentication and global dependencies.
|
||||
3. Reloading the controller module to apply patches to decorators.
|
||||
4. cleaning up sys.modules afterwards.
|
||||
"""
|
||||
target_module = "controllers.console.feature"
|
||||
alias_module = "api.controllers.console.feature"
|
||||
|
||||
# 1. Prepare isolated router
|
||||
temp_router = _create_isolated_router()
|
||||
|
||||
# 2. Apply patches
|
||||
try:
|
||||
with _patch_auth_and_router(temp_router):
|
||||
# 3. Reload module to register routes on the temp_router
|
||||
feature_module = _force_reload_module(target_module, alias_module)
|
||||
|
||||
yield feature_module
|
||||
|
||||
finally:
|
||||
# 4. Teardown: Clean up sys.modules
|
||||
_cleanup_modules(target_module, alias_module)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Test Cases
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "service_mock_path", "mock_model_instance", "json_key"),
|
||||
[
|
||||
(
|
||||
"/console/api/features",
|
||||
"controllers.console.feature.FeatureService.get_features",
|
||||
FeatureModel(can_replace_logo=True),
|
||||
"features",
|
||||
),
|
||||
(
|
||||
"/console/api/system-features",
|
||||
"controllers.console.feature.FeatureService.get_system_features",
|
||||
SystemFeatureModel(enable_marketplace=True),
|
||||
"features",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_console_features_success(app, mock_feature_module_env, url, service_mock_path, mock_model_instance, json_key):
|
||||
"""
|
||||
Tests that the feature APIs return a 200 OK status and correct JSON structure.
|
||||
"""
|
||||
# Patch the service layer to return our mock model instance
|
||||
with patch(service_mock_path, return_value=mock_model_instance):
|
||||
# Initialize the API extension
|
||||
ext_fastopenapi.init_app(app)
|
||||
|
||||
client = app.test_client()
|
||||
response = client.get(url)
|
||||
|
||||
# Assertions
|
||||
assert response.status_code == 200, f"Request failed with status {response.status_code}: {response.text}"
|
||||
|
||||
# Verify the JSON response matches the Pydantic model dump
|
||||
expected_data = mock_model_instance.model_dump(mode="json")
|
||||
assert response.get_json() == {json_key: expected_data}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "service_mock_path"),
|
||||
[
|
||||
("/console/api/features", "controllers.console.feature.FeatureService.get_features"),
|
||||
("/console/api/system-features", "controllers.console.feature.FeatureService.get_system_features"),
|
||||
],
|
||||
)
|
||||
def test_console_features_service_error(app, mock_feature_module_env, url, service_mock_path):
|
||||
"""
|
||||
Tests how the application handles Service layer errors.
|
||||
|
||||
Note: When an exception occurs in the view, it is typically caught by the framework
|
||||
(Flask or the OpenAPI wrapper) and converted to a 500 error response.
|
||||
This test verifies that the application returns a 500 status code.
|
||||
"""
|
||||
# Simulate a service failure
|
||||
with patch(service_mock_path, side_effect=ValueError("Service Failure")):
|
||||
ext_fastopenapi.init_app(app)
|
||||
client = app.test_client()
|
||||
|
||||
# When an exception occurs in the view, it is typically caught by the framework
|
||||
# (Flask or the OpenAPI wrapper) and converted to a 500 error response.
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == 500
|
||||
# Check if the error details are exposed in the response (depends on error handler config)
|
||||
# We accept either generic 500 or the specific error message
|
||||
assert "Service Failure" in response.text or "Internal Server Error" in response.text
|
||||
|
||||
|
||||
def test_system_features_unauthenticated(app, mock_feature_module_env):
|
||||
"""
|
||||
Tests that /console/api/system-features endpoint works without authentication.
|
||||
|
||||
This test verifies the try-except block in get_system_features that handles
|
||||
unauthenticated requests by passing is_authenticated=False to the service layer.
|
||||
"""
|
||||
feature_module = mock_feature_module_env
|
||||
|
||||
# Override the behavior of the current_user mock
|
||||
# The fixture patched 'libs.login.current_user', so 'controllers.console.feature.current_user'
|
||||
# refers to that same Mock object.
|
||||
mock_user = feature_module.current_user
|
||||
|
||||
# Simulate property access raising Unauthorized
|
||||
# Note: We must reset side_effect if it was set, or set it here.
|
||||
# The fixture initialized it as MagicMock(is_authenticated=True).
|
||||
# We want type(mock_user).is_authenticated to raise Unauthorized.
|
||||
type(mock_user).is_authenticated = PropertyMock(side_effect=Unauthorized)
|
||||
|
||||
# Patch the service layer for this specific test
|
||||
with patch("controllers.console.feature.FeatureService.get_system_features") as mock_service:
|
||||
# Setup mock service return value
|
||||
mock_model = SystemFeatureModel(enable_marketplace=True)
|
||||
mock_service.return_value = mock_model
|
||||
|
||||
# Initialize app
|
||||
ext_fastopenapi.init_app(app)
|
||||
client = app.test_client()
|
||||
|
||||
# Act
|
||||
response = client.get("/console/api/system-features")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200, f"Request failed: {response.text}"
|
||||
|
||||
# Verify service was called with is_authenticated=False
|
||||
mock_service.assert_called_once_with(is_authenticated=False)
|
||||
|
||||
# Verify response body
|
||||
expected_data = mock_model.model_dump(mode="json")
|
||||
assert response.get_json() == {"features": expected_data}
|
||||
@@ -1,364 +0,0 @@
|
||||
"""Endpoint tests for controllers.console.workspace.tool_providers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
import importlib
|
||||
from contextlib import contextmanager
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask.views import MethodView
|
||||
|
||||
if not hasattr(builtins, "MethodView"):
|
||||
builtins.MethodView = MethodView # type: ignore[attr-defined]
|
||||
|
||||
|
||||
_CONTROLLER_MODULE: ModuleType | None = None
|
||||
_WRAPS_MODULE: ModuleType | None = None
|
||||
_CONTROLLER_PATCHERS: list[patch] = []
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _mock_db():
|
||||
mock_session = SimpleNamespace(query=lambda *args, **kwargs: SimpleNamespace(first=lambda: True))
|
||||
with patch("extensions.ext_database.db.session", mock_session):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app() -> Flask:
|
||||
flask_app = Flask(__name__)
|
||||
flask_app.config["TESTING"] = True
|
||||
return flask_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller_module(monkeypatch: pytest.MonkeyPatch):
|
||||
module_name = "controllers.console.workspace.tool_providers"
|
||||
global _CONTROLLER_MODULE
|
||||
if _CONTROLLER_MODULE is None:
|
||||
|
||||
def _noop(func):
|
||||
return func
|
||||
|
||||
patch_targets = [
|
||||
("libs.login.login_required", _noop),
|
||||
("controllers.console.wraps.setup_required", _noop),
|
||||
("controllers.console.wraps.account_initialization_required", _noop),
|
||||
("controllers.console.wraps.is_admin_or_owner_required", _noop),
|
||||
("controllers.console.wraps.enterprise_license_required", _noop),
|
||||
]
|
||||
for target, value in patch_targets:
|
||||
patcher = patch(target, value)
|
||||
patcher.start()
|
||||
_CONTROLLER_PATCHERS.append(patcher)
|
||||
monkeypatch.setenv("DIFY_SETUP_READY", "true")
|
||||
with _mock_db():
|
||||
_CONTROLLER_MODULE = importlib.import_module(module_name)
|
||||
|
||||
module = _CONTROLLER_MODULE
|
||||
monkeypatch.setattr(module, "jsonable_encoder", lambda payload: payload)
|
||||
|
||||
# Ensure decorators that consult deployment edition do not reach the database.
|
||||
global _WRAPS_MODULE
|
||||
wraps_module = importlib.import_module("controllers.console.wraps")
|
||||
_WRAPS_MODULE = wraps_module
|
||||
monkeypatch.setattr(module.dify_config, "EDITION", "CLOUD")
|
||||
monkeypatch.setattr(wraps_module.dify_config, "EDITION", "CLOUD")
|
||||
|
||||
login_module = importlib.import_module("libs.login")
|
||||
monkeypatch.setattr(login_module, "check_csrf_token", lambda *args, **kwargs: None)
|
||||
return module
|
||||
|
||||
|
||||
def _mock_account(user_id: str = "user-123") -> SimpleNamespace:
|
||||
return SimpleNamespace(id=user_id, status="active", is_authenticated=True, current_tenant_id=None)
|
||||
|
||||
|
||||
def _set_current_account(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
controller_module: ModuleType,
|
||||
user: SimpleNamespace,
|
||||
tenant_id: str,
|
||||
) -> None:
|
||||
def _getter():
|
||||
return user, tenant_id
|
||||
|
||||
user.current_tenant_id = tenant_id
|
||||
|
||||
monkeypatch.setattr(controller_module, "current_account_with_tenant", _getter)
|
||||
if _WRAPS_MODULE is not None:
|
||||
monkeypatch.setattr(_WRAPS_MODULE, "current_account_with_tenant", _getter)
|
||||
|
||||
login_module = importlib.import_module("libs.login")
|
||||
monkeypatch.setattr(login_module, "_get_user", lambda: user)
|
||||
|
||||
|
||||
def test_tool_provider_list_calls_service_with_query(
|
||||
app: Flask, controller_module: ModuleType, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
user = _mock_account()
|
||||
_set_current_account(monkeypatch, controller_module, user, "tenant-456")
|
||||
|
||||
service_mock = MagicMock(return_value=[{"provider": "builtin"}])
|
||||
monkeypatch.setattr(controller_module.ToolCommonService, "list_tool_providers", service_mock)
|
||||
|
||||
with app.test_request_context("/workspaces/current/tool-providers?type=builtin"):
|
||||
response = controller_module.ToolProviderListApi().get()
|
||||
|
||||
assert response == [{"provider": "builtin"}]
|
||||
service_mock.assert_called_once_with(user.id, "tenant-456", "builtin")
|
||||
|
||||
|
||||
def test_builtin_provider_add_passes_payload(
|
||||
app: Flask, controller_module: ModuleType, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
user = _mock_account()
|
||||
_set_current_account(monkeypatch, controller_module, user, "tenant-456")
|
||||
|
||||
service_mock = MagicMock(return_value={"status": "ok"})
|
||||
monkeypatch.setattr(controller_module.BuiltinToolManageService, "add_builtin_tool_provider", service_mock)
|
||||
|
||||
payload = {
|
||||
"credentials": {"api_key": "sk-test"},
|
||||
"name": "MyTool",
|
||||
"type": controller_module.CredentialType.API_KEY,
|
||||
}
|
||||
|
||||
with app.test_request_context(
|
||||
"/workspaces/current/tool-provider/builtin/openai/add",
|
||||
method="POST",
|
||||
json=payload,
|
||||
):
|
||||
response = controller_module.ToolBuiltinProviderAddApi().post(provider="openai")
|
||||
|
||||
assert response == {"status": "ok"}
|
||||
service_mock.assert_called_once_with(
|
||||
user_id="user-123",
|
||||
tenant_id="tenant-456",
|
||||
provider="openai",
|
||||
credentials={"api_key": "sk-test"},
|
||||
name="MyTool",
|
||||
api_type=controller_module.CredentialType.API_KEY,
|
||||
)
|
||||
|
||||
|
||||
def test_builtin_provider_tools_get(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch):
|
||||
user = _mock_account("user-tenant-789")
|
||||
_set_current_account(monkeypatch, controller_module, user, "tenant-789")
|
||||
|
||||
service_mock = MagicMock(return_value=[{"name": "tool-a"}])
|
||||
monkeypatch.setattr(controller_module.BuiltinToolManageService, "list_builtin_tool_provider_tools", service_mock)
|
||||
monkeypatch.setattr(controller_module, "jsonable_encoder", lambda payload: payload)
|
||||
|
||||
with app.test_request_context(
|
||||
"/workspaces/current/tool-provider/builtin/my-provider/tools",
|
||||
method="GET",
|
||||
):
|
||||
response = controller_module.ToolBuiltinProviderListToolsApi().get(provider="my-provider")
|
||||
|
||||
assert response == [{"name": "tool-a"}]
|
||||
service_mock.assert_called_once_with("tenant-789", "my-provider")
|
||||
|
||||
|
||||
def test_builtin_provider_info_get(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch):
|
||||
user = _mock_account("user-tenant-9")
|
||||
_set_current_account(monkeypatch, controller_module, user, "tenant-9")
|
||||
service_mock = MagicMock(return_value={"info": True})
|
||||
monkeypatch.setattr(controller_module.BuiltinToolManageService, "get_builtin_tool_provider_info", service_mock)
|
||||
|
||||
with app.test_request_context("/info", method="GET"):
|
||||
resp = controller_module.ToolBuiltinProviderInfoApi().get(provider="demo")
|
||||
|
||||
assert resp == {"info": True}
|
||||
service_mock.assert_called_once_with("tenant-9", "demo")
|
||||
|
||||
|
||||
def test_builtin_provider_credentials_get(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch):
|
||||
user = _mock_account("user-tenant-cred")
|
||||
_set_current_account(monkeypatch, controller_module, user, "tenant-cred")
|
||||
service_mock = MagicMock(return_value=[{"cred": 1}])
|
||||
monkeypatch.setattr(
|
||||
controller_module.BuiltinToolManageService,
|
||||
"get_builtin_tool_provider_credentials",
|
||||
service_mock,
|
||||
)
|
||||
|
||||
with app.test_request_context("/creds", method="GET"):
|
||||
resp = controller_module.ToolBuiltinProviderGetCredentialsApi().get(provider="demo")
|
||||
|
||||
assert resp == [{"cred": 1}]
|
||||
service_mock.assert_called_once_with(tenant_id="tenant-cred", provider_name="demo")
|
||||
|
||||
|
||||
def test_api_provider_remote_schema_get(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch):
|
||||
user = _mock_account()
|
||||
_set_current_account(monkeypatch, controller_module, user, "tenant-10")
|
||||
service_mock = MagicMock(return_value={"schema": "ok"})
|
||||
monkeypatch.setattr(controller_module.ApiToolManageService, "get_api_tool_provider_remote_schema", service_mock)
|
||||
|
||||
with app.test_request_context("/remote?url=https://example.com/"):
|
||||
resp = controller_module.ToolApiProviderGetRemoteSchemaApi().get()
|
||||
|
||||
assert resp == {"schema": "ok"}
|
||||
service_mock.assert_called_once_with(user.id, "tenant-10", "https://example.com/")
|
||||
|
||||
|
||||
def test_api_provider_list_tools_get(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch):
|
||||
user = _mock_account()
|
||||
_set_current_account(monkeypatch, controller_module, user, "tenant-11")
|
||||
service_mock = MagicMock(return_value=[{"tool": "t"}])
|
||||
monkeypatch.setattr(controller_module.ApiToolManageService, "list_api_tool_provider_tools", service_mock)
|
||||
|
||||
with app.test_request_context("/tools?provider=foo"):
|
||||
resp = controller_module.ToolApiProviderListToolsApi().get()
|
||||
|
||||
assert resp == [{"tool": "t"}]
|
||||
service_mock.assert_called_once_with(user.id, "tenant-11", "foo")
|
||||
|
||||
|
||||
def test_api_provider_get(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch):
|
||||
user = _mock_account()
|
||||
_set_current_account(monkeypatch, controller_module, user, "tenant-12")
|
||||
service_mock = MagicMock(return_value={"provider": "foo"})
|
||||
monkeypatch.setattr(controller_module.ApiToolManageService, "get_api_tool_provider", service_mock)
|
||||
|
||||
with app.test_request_context("/get?provider=foo"):
|
||||
resp = controller_module.ToolApiProviderGetApi().get()
|
||||
|
||||
assert resp == {"provider": "foo"}
|
||||
service_mock.assert_called_once_with(user.id, "tenant-12", "foo")
|
||||
|
||||
|
||||
def test_builtin_provider_credentials_schema_get(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch):
|
||||
user = _mock_account("user-tenant-13")
|
||||
_set_current_account(monkeypatch, controller_module, user, "tenant-13")
|
||||
service_mock = MagicMock(return_value={"schema": True})
|
||||
monkeypatch.setattr(
|
||||
controller_module.BuiltinToolManageService,
|
||||
"list_builtin_provider_credentials_schema",
|
||||
service_mock,
|
||||
)
|
||||
|
||||
with app.test_request_context("/schema", method="GET"):
|
||||
resp = controller_module.ToolBuiltinProviderCredentialsSchemaApi().get(
|
||||
provider="demo", credential_type="api-key"
|
||||
)
|
||||
|
||||
assert resp == {"schema": True}
|
||||
service_mock.assert_called_once()
|
||||
|
||||
|
||||
def test_workflow_provider_get_by_tool(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch):
|
||||
user = _mock_account()
|
||||
_set_current_account(monkeypatch, controller_module, user, "tenant-wf")
|
||||
tool_service = MagicMock(return_value={"wf": 1})
|
||||
monkeypatch.setattr(
|
||||
controller_module.WorkflowToolManageService,
|
||||
"get_workflow_tool_by_tool_id",
|
||||
tool_service,
|
||||
)
|
||||
|
||||
tool_id = "00000000-0000-0000-0000-000000000001"
|
||||
with app.test_request_context(f"/workflow?workflow_tool_id={tool_id}"):
|
||||
resp = controller_module.ToolWorkflowProviderGetApi().get()
|
||||
|
||||
assert resp == {"wf": 1}
|
||||
tool_service.assert_called_once_with(user.id, "tenant-wf", tool_id)
|
||||
|
||||
|
||||
def test_workflow_provider_get_by_app(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch):
|
||||
user = _mock_account()
|
||||
_set_current_account(monkeypatch, controller_module, user, "tenant-wf2")
|
||||
service_mock = MagicMock(return_value={"app": 1})
|
||||
monkeypatch.setattr(
|
||||
controller_module.WorkflowToolManageService,
|
||||
"get_workflow_tool_by_app_id",
|
||||
service_mock,
|
||||
)
|
||||
|
||||
app_id = "00000000-0000-0000-0000-000000000002"
|
||||
with app.test_request_context(f"/workflow?workflow_app_id={app_id}"):
|
||||
resp = controller_module.ToolWorkflowProviderGetApi().get()
|
||||
|
||||
assert resp == {"app": 1}
|
||||
service_mock.assert_called_once_with(user.id, "tenant-wf2", app_id)
|
||||
|
||||
|
||||
def test_workflow_provider_list_tools(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch):
|
||||
user = _mock_account()
|
||||
_set_current_account(monkeypatch, controller_module, user, "tenant-wf3")
|
||||
service_mock = MagicMock(return_value=[{"id": 1}])
|
||||
monkeypatch.setattr(controller_module.WorkflowToolManageService, "list_single_workflow_tools", service_mock)
|
||||
|
||||
tool_id = "00000000-0000-0000-0000-000000000003"
|
||||
with app.test_request_context(f"/workflow/tools?workflow_tool_id={tool_id}"):
|
||||
resp = controller_module.ToolWorkflowProviderListToolApi().get()
|
||||
|
||||
assert resp == [{"id": 1}]
|
||||
service_mock.assert_called_once_with(user.id, "tenant-wf3", tool_id)
|
||||
|
||||
|
||||
def test_builtin_tools_list(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch):
|
||||
user = _mock_account()
|
||||
_set_current_account(monkeypatch, controller_module, user, "tenant-bt")
|
||||
|
||||
provider = SimpleNamespace(to_dict=lambda: {"name": "builtin"})
|
||||
monkeypatch.setattr(
|
||||
controller_module.BuiltinToolManageService,
|
||||
"list_builtin_tools",
|
||||
MagicMock(return_value=[provider]),
|
||||
)
|
||||
|
||||
with app.test_request_context("/tools/builtin"):
|
||||
resp = controller_module.ToolBuiltinListApi().get()
|
||||
|
||||
assert resp == [{"name": "builtin"}]
|
||||
|
||||
|
||||
def test_api_tools_list(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch):
|
||||
user = _mock_account("user-tenant-api")
|
||||
_set_current_account(monkeypatch, controller_module, user, "tenant-api")
|
||||
|
||||
provider = SimpleNamespace(to_dict=lambda: {"name": "api"})
|
||||
monkeypatch.setattr(
|
||||
controller_module.ApiToolManageService,
|
||||
"list_api_tools",
|
||||
MagicMock(return_value=[provider]),
|
||||
)
|
||||
|
||||
with app.test_request_context("/tools/api"):
|
||||
resp = controller_module.ToolApiListApi().get()
|
||||
|
||||
assert resp == [{"name": "api"}]
|
||||
|
||||
|
||||
def test_workflow_tools_list(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch):
|
||||
user = _mock_account()
|
||||
_set_current_account(monkeypatch, controller_module, user, "tenant-wf4")
|
||||
|
||||
provider = SimpleNamespace(to_dict=lambda: {"name": "wf"})
|
||||
monkeypatch.setattr(
|
||||
controller_module.WorkflowToolManageService,
|
||||
"list_tenant_workflow_tools",
|
||||
MagicMock(return_value=[provider]),
|
||||
)
|
||||
|
||||
with app.test_request_context("/tools/workflow"):
|
||||
resp = controller_module.ToolWorkflowListApi().get()
|
||||
|
||||
assert resp == [{"name": "wf"}]
|
||||
|
||||
|
||||
def test_tool_labels_list(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch):
|
||||
user = _mock_account("user-label")
|
||||
_set_current_account(monkeypatch, controller_module, user, "tenant-labels")
|
||||
monkeypatch.setattr(controller_module.ToolLabelsService, "list_tool_labels", lambda: ["a", "b"])
|
||||
|
||||
with app.test_request_context("/tool-labels"):
|
||||
resp = controller_module.ToolLabelsApi().get()
|
||||
|
||||
assert resp == ["a", "b"]
|
||||
Generated
+5
-5
@@ -1368,7 +1368,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.11.4"
|
||||
version = "1.12.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aliyun-log-python-sdk" },
|
||||
@@ -1707,7 +1707,7 @@ dev = [
|
||||
{ name = "types-openpyxl", specifier = "~=3.1.5" },
|
||||
{ name = "types-pexpect", specifier = "~=4.9.0" },
|
||||
{ name = "types-protobuf", specifier = "~=5.29.1" },
|
||||
{ name = "types-psutil", specifier = "~=7.2.2" },
|
||||
{ name = "types-psutil", specifier = "~=7.0.0" },
|
||||
{ name = "types-psycopg2", specifier = "~=2.9.21" },
|
||||
{ name = "types-pygments", specifier = "~=2.19.0" },
|
||||
{ name = "types-pymysql", specifier = "~=1.1.0" },
|
||||
@@ -6508,11 +6508,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-psutil"
|
||||
version = "7.2.2.20260130"
|
||||
version = "7.0.0.20251116"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/14/fc5fb0a6ddfadf68c27e254a02ececd4d5c7fdb0efcb7e7e917a183497fb/types_psutil-7.2.2.20260130.tar.gz", hash = "sha256:15b0ab69c52841cf9ce3c383e8480c620a4d13d6a8e22b16978ebddac5590950", size = 26535, upload-time = "2026-01-30T03:58:14.116Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/ec/c1e9308b91582cad1d7e7d3007fd003ef45a62c2500f8219313df5fc3bba/types_psutil-7.0.0.20251116.tar.gz", hash = "sha256:92b5c78962e55ce1ed7b0189901a4409ece36ab9fd50c3029cca7e681c606c8a", size = 22192, upload-time = "2025-11-16T03:10:32.859Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/d7/60974b7e31545d3768d1770c5fe6e093182c3bfd819429b33133ba6b3e89/types_psutil-7.2.2.20260130-py3-none-any.whl", hash = "sha256:15523a3caa7b3ff03ac7f9b78a6470a59f88f48df1d74a39e70e06d2a99107da", size = 32876, upload-time = "2026-01-30T03:58:13.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/0e/11ba08a5375c21039ed5f8e6bba41e9452fb69f0e2f7ee05ed5cca2a2cdf/types_psutil-7.0.0.20251116-py3-none-any.whl", hash = "sha256:74c052de077c2024b85cd435e2cba971165fe92a5eace79cbeb821e776dbc047", size = 25376, upload-time = "2025-11-16T03:10:31.813Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -21,7 +21,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.11.4
|
||||
image: langgenius/dify-api:1.12.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -63,7 +63,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.11.4
|
||||
image: langgenius/dify-api:1.12.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -102,7 +102,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.11.4
|
||||
image: langgenius/dify-api:1.12.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -132,7 +132,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.11.4
|
||||
image: langgenius/dify-web:1.12.0
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
|
||||
@@ -707,7 +707,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.11.4
|
||||
image: langgenius/dify-api:1.12.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -749,7 +749,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.11.4
|
||||
image: langgenius/dify-api:1.12.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -788,7 +788,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.11.4
|
||||
image: langgenius/dify-api:1.12.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -818,7 +818,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.11.4
|
||||
image: langgenius/dify-web:1.12.0
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
{"openapi":"3.0.0","info":{"title":"Dify API (FastOpenAPI PoC)","version":"1.0","description":"FastOpenAPI proof of concept for Dify API"},"paths":{"/console/api/ping":{"get":{"summary":"Health check endpoint for connection testing.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PingResponse"}}}}},"tags":["console"]}}},"components":{"schemas":{"ErrorSchema":{"type":"object","properties":{"error":{"type":"object","properties":{"type":{"type":"string"},"message":{"type":"string"},"status":{"type":"integer"},"details":{"type":"string"}},"required":["type","message","status"]}},"required":["error"]},"PingResponse":{"properties":{"result":{"description":"Health check result","examples":["pong"],"title":"Result","type":"string"}},"required":["result"],"title":"PingResponse","type":"object"}}}}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -124,7 +124,7 @@ describe('CreateAppModal', () => {
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
|
||||
fireEvent.change(nameInput, { target: { value: 'My App' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Create/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Create' }))
|
||||
|
||||
await waitFor(() => expect(mockCreateApp).toHaveBeenCalledWith({
|
||||
name: 'My App',
|
||||
@@ -152,7 +152,7 @@ describe('CreateAppModal', () => {
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
|
||||
fireEvent.change(nameInput, { target: { value: 'My App' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Create/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Create' }))
|
||||
|
||||
await waitFor(() => expect(mockCreateApp).toHaveBeenCalled())
|
||||
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'boom' })
|
||||
|
||||
@@ -138,7 +138,7 @@ describe('CreateAppModal', () => {
|
||||
setup({ appName: 'My App', isEditModal: false })
|
||||
|
||||
expect(screen.getByText('explore.appCustomize.title:{"name":"My App"}')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -146,7 +146,7 @@ describe('CreateAppModal', () => {
|
||||
setup({ isEditModal: true, appMode: AppModeEnum.CHAT, max_active_requests: 5 })
|
||||
|
||||
expect(screen.getByText('app.editAppTitle')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.save/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
expect((screen.getByRole('spinbutton') as HTMLInputElement).value).toBe('5')
|
||||
})
|
||||
@@ -166,7 +166,7 @@ describe('CreateAppModal', () => {
|
||||
it('should not render modal content when hidden', () => {
|
||||
setup({ show: false })
|
||||
|
||||
expect(screen.queryByRole('button', { name: /common\.operation\.create/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.create' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -175,13 +175,13 @@ describe('CreateAppModal', () => {
|
||||
it('should disable confirm action when confirmDisabled is true', () => {
|
||||
setup({ confirmDisabled: true })
|
||||
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable confirm action when appName is empty', () => {
|
||||
setup({ appName: ' ' })
|
||||
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -245,7 +245,7 @@ describe('CreateAppModal', () => {
|
||||
setup({ isEditModal: false })
|
||||
|
||||
expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should allow saving when apps quota is reached in edit mode', () => {
|
||||
@@ -257,7 +257,7 @@ describe('CreateAppModal', () => {
|
||||
setup({ isEditModal: true })
|
||||
|
||||
expect(screen.queryByText('billing.apps.fullTip2')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.save/ })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -384,7 +384,7 @@ describe('CreateAppModal', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
@@ -433,7 +433,7 @@ describe('CreateAppModal', () => {
|
||||
expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument()
|
||||
|
||||
// Submit and verify the payload uses the original icon (cancel reverts to props)
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
@@ -471,7 +471,7 @@ describe('CreateAppModal', () => {
|
||||
appIconBackground: '#000000',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
@@ -495,7 +495,7 @@ describe('CreateAppModal', () => {
|
||||
const { onConfirm } = setup({ appDescription: 'Old description' })
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder'), { target: { value: 'Updated description' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
@@ -512,7 +512,7 @@ describe('CreateAppModal', () => {
|
||||
appIconBackground: null,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
@@ -536,7 +536,7 @@ describe('CreateAppModal', () => {
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '12' } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
@@ -551,7 +551,7 @@ describe('CreateAppModal', () => {
|
||||
it('should omit max_active_requests when input is empty', () => {
|
||||
const { onConfirm } = setup({ isEditModal: true, max_active_requests: null })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
@@ -564,7 +564,7 @@ describe('CreateAppModal', () => {
|
||||
const { onConfirm } = setup({ isEditModal: true, max_active_requests: null })
|
||||
|
||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: 'abc' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
@@ -576,7 +576,7 @@ describe('CreateAppModal', () => {
|
||||
it('should show toast error and not submit when name becomes empty before debounced submit runs', () => {
|
||||
const { onConfirm, onHide } = setup({ appName: 'My App' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), { target: { value: ' ' } })
|
||||
|
||||
act(() => {
|
||||
|
||||
@@ -81,205 +81,4 @@ describe('CommandSelector', () => {
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('/zen')
|
||||
})
|
||||
|
||||
it('should show all slash commands when no filter provided', () => {
|
||||
const actions = createActions()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter=""
|
||||
originalQuery="/"
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
|
||||
// Should show the zen command from mock
|
||||
expect(screen.getByText('/zen')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should exclude slash action when in @ mode', () => {
|
||||
const actions = {
|
||||
...createActions(),
|
||||
slash: {
|
||||
key: '/',
|
||||
shortcut: '/',
|
||||
title: 'Slash',
|
||||
search: vi.fn(),
|
||||
description: '',
|
||||
} as ActionItem,
|
||||
}
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter=""
|
||||
originalQuery="@"
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
|
||||
// Should show @ commands but not /
|
||||
expect(screen.getByText('@app')).toBeInTheDocument()
|
||||
expect(screen.queryByText('/')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show all actions when no filter in @ mode', () => {
|
||||
const actions = createActions()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter=""
|
||||
originalQuery="@"
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('@app')).toBeInTheDocument()
|
||||
expect(screen.getByText('@plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should set default command value when items exist but value does not', () => {
|
||||
const actions = createActions()
|
||||
const onSelect = vi.fn()
|
||||
const onCommandValueChange = vi.fn()
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter=""
|
||||
originalQuery="@"
|
||||
commandValue="non-existent"
|
||||
onCommandValueChange={onCommandValueChange}
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
|
||||
expect(onCommandValueChange).toHaveBeenCalledWith('@app')
|
||||
})
|
||||
|
||||
it('should NOT set command value when value already exists in items', () => {
|
||||
const actions = createActions()
|
||||
const onSelect = vi.fn()
|
||||
const onCommandValueChange = vi.fn()
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter=""
|
||||
originalQuery="@"
|
||||
commandValue="@app"
|
||||
onCommandValueChange={onCommandValueChange}
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
|
||||
expect(onCommandValueChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show no matching commands message when filter has no results', () => {
|
||||
const actions = createActions()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter="nonexistent"
|
||||
originalQuery="@nonexistent"
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.gotoAnything.tryDifferentSearch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show no matching commands for slash mode with no results', () => {
|
||||
const actions = createActions()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter="nonexistentcommand"
|
||||
originalQuery="/nonexistentcommand"
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description for @ commands', () => {
|
||||
const actions = createActions()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter=""
|
||||
originalQuery="@"
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.actions.searchApplicationsDesc')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.gotoAnything.actions.searchPluginsDesc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render group header for @ mode', () => {
|
||||
const actions = createActions()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter=""
|
||||
originalQuery="@"
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.selectSearchType')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render group header for slash mode', () => {
|
||||
const actions = createActions()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter=""
|
||||
originalQuery="/"
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.groups.commands')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import EmptyState from './empty-state'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string, shortcuts?: string }) => {
|
||||
if (options?.shortcuts !== undefined)
|
||||
return `${key}:${options.shortcuts}`
|
||||
return `${options?.ns || 'common'}.${key}`
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('EmptyState', () => {
|
||||
describe('loading variant', () => {
|
||||
it('should render loading spinner', () => {
|
||||
render(<EmptyState variant="loading" />)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.searching')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have spinner animation class', () => {
|
||||
const { container } = render(<EmptyState variant="loading" />)
|
||||
|
||||
const spinner = container.querySelector('.animate-spin')
|
||||
expect(spinner).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('error variant', () => {
|
||||
it('should render error message when error has message', () => {
|
||||
const error = new Error('Connection failed')
|
||||
render(<EmptyState variant="error" error={error} />)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.searchFailed')).toBeInTheDocument()
|
||||
expect(screen.getByText('Connection failed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render generic error when error has no message', () => {
|
||||
render(<EmptyState variant="error" error={null} />)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.searchTemporarilyUnavailable')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.gotoAnything.servicesUnavailableMessage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render generic error when error is undefined', () => {
|
||||
render(<EmptyState variant="error" />)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.searchTemporarilyUnavailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have red error text styling', () => {
|
||||
const error = new Error('Test error')
|
||||
const { container } = render(<EmptyState variant="error" error={error} />)
|
||||
|
||||
const errorText = container.querySelector('.text-red-500')
|
||||
expect(errorText).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('default variant', () => {
|
||||
it('should render search title', () => {
|
||||
render(<EmptyState variant="default" />)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.searchTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all hint messages', () => {
|
||||
render(<EmptyState variant="default" />)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.searchHint')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.gotoAnything.commandHint')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.gotoAnything.slashHint')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('no-results variant', () => {
|
||||
describe('general search mode', () => {
|
||||
it('should render generic no results message', () => {
|
||||
render(<EmptyState variant="no-results" searchMode="general" />)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.noResults')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show specific search hint with shortcuts', () => {
|
||||
const Actions = {
|
||||
app: { key: '@app', shortcut: '@app' },
|
||||
plugin: { key: '@plugin', shortcut: '@plugin' },
|
||||
} as unknown as Record<string, import('../actions/types').ActionItem>
|
||||
render(<EmptyState variant="no-results" searchMode="general" Actions={Actions} />)
|
||||
|
||||
expect(screen.getByText('gotoAnything.emptyState.trySpecificSearch:@app, @plugin')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('app search mode', () => {
|
||||
it('should render no apps found message', () => {
|
||||
render(<EmptyState variant="no-results" searchMode="@app" />)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.emptyState.noAppsFound')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show try different term hint', () => {
|
||||
render(<EmptyState variant="no-results" searchMode="@app" />)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.emptyState.tryDifferentTerm')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin search mode', () => {
|
||||
it('should render no plugins found message', () => {
|
||||
render(<EmptyState variant="no-results" searchMode="@plugin" />)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.emptyState.noPluginsFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('knowledge search mode', () => {
|
||||
it('should render no knowledge bases found message', () => {
|
||||
render(<EmptyState variant="no-results" searchMode="@knowledge" />)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.emptyState.noKnowledgeBasesFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('node search mode', () => {
|
||||
it('should render no workflow nodes found message', () => {
|
||||
render(<EmptyState variant="no-results" searchMode="@node" />)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.emptyState.noWorkflowNodesFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('unknown search mode', () => {
|
||||
it('should fallback to generic no results message', () => {
|
||||
render(<EmptyState variant="no-results" searchMode="@unknown" />)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.noResults')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('default props', () => {
|
||||
it('should use general as default searchMode', () => {
|
||||
render(<EmptyState variant="no-results" />)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.noResults')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use empty object as default Actions', () => {
|
||||
render(<EmptyState variant="no-results" searchMode="general" />)
|
||||
|
||||
// Should show empty shortcuts
|
||||
expect(screen.getByText('gotoAnything.emptyState.trySpecificSearch:')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,105 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { ActionItem } from '../actions/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export type EmptyStateVariant = 'no-results' | 'error' | 'default' | 'loading'
|
||||
|
||||
export type EmptyStateProps = {
|
||||
variant: EmptyStateVariant
|
||||
searchMode?: string
|
||||
error?: Error | null
|
||||
Actions?: Record<string, ActionItem>
|
||||
}
|
||||
|
||||
const EmptyState: FC<EmptyStateProps> = ({
|
||||
variant,
|
||||
searchMode = 'general',
|
||||
error,
|
||||
Actions = {},
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (variant === 'loading') {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600"></div>
|
||||
<span className="text-sm">{t('gotoAnything.searching', { ns: 'app' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'error') {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-red-500">
|
||||
{error?.message
|
||||
? t('gotoAnything.searchFailed', { ns: 'app' })
|
||||
: t('gotoAnything.searchTemporarilyUnavailable', { ns: 'app' })}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-quaternary">
|
||||
{error?.message || t('gotoAnything.servicesUnavailableMessage', { ns: 'app' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'default') {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('gotoAnything.searchTitle', { ns: 'app' })}</div>
|
||||
<div className="mt-3 space-y-1 text-xs text-text-quaternary">
|
||||
<div>{t('gotoAnything.searchHint', { ns: 'app' })}</div>
|
||||
<div>{t('gotoAnything.commandHint', { ns: 'app' })}</div>
|
||||
<div>{t('gotoAnything.slashHint', { ns: 'app' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// variant === 'no-results'
|
||||
const isCommandSearch = searchMode !== 'general'
|
||||
const commandType = isCommandSearch ? searchMode.replace('@', '') : ''
|
||||
|
||||
const getNoResultsMessage = () => {
|
||||
if (!isCommandSearch) {
|
||||
return t('gotoAnything.noResults', { ns: 'app' })
|
||||
}
|
||||
|
||||
const keyMap = {
|
||||
app: 'gotoAnything.emptyState.noAppsFound',
|
||||
plugin: 'gotoAnything.emptyState.noPluginsFound',
|
||||
knowledge: 'gotoAnything.emptyState.noKnowledgeBasesFound',
|
||||
node: 'gotoAnything.emptyState.noWorkflowNodesFound',
|
||||
} as const
|
||||
|
||||
return t(keyMap[commandType as keyof typeof keyMap] || 'gotoAnything.noResults', { ns: 'app' })
|
||||
}
|
||||
|
||||
const getHintMessage = () => {
|
||||
if (isCommandSearch) {
|
||||
return t('gotoAnything.emptyState.tryDifferentTerm', { ns: 'app' })
|
||||
}
|
||||
|
||||
const shortcuts = Object.values(Actions).map(action => action.shortcut).join(', ')
|
||||
return t('gotoAnything.emptyState.trySpecificSearch', { ns: 'app', shortcuts })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{getNoResultsMessage()}</div>
|
||||
<div className="mt-1 text-xs text-text-quaternary">{getHintMessage()}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyState
|
||||
@@ -1,273 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Footer from './footer'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string, count?: number, scope?: string }) => {
|
||||
if (options?.count !== undefined)
|
||||
return `${key}:${options.count}`
|
||||
if (options?.scope)
|
||||
return `${key}:${options.scope}`
|
||||
return `${options?.ns || 'common'}.${key}`
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Footer', () => {
|
||||
describe('left content', () => {
|
||||
describe('when there are results', () => {
|
||||
it('should show result count', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={5}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('gotoAnything.resultCount:5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show scope when not in general mode', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={3}
|
||||
searchMode="@app"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('gotoAnything.inScope:app')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT show scope when in general mode', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={3}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/inScope/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there is an error', () => {
|
||||
it('should show error message', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={true}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.someServicesUnavailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have red text styling', () => {
|
||||
const { container } = render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={true}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
const errorText = container.querySelector('.text-red-500')
|
||||
expect(errorText).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error even with results', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={5}
|
||||
searchMode="general"
|
||||
isError={true}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.someServicesUnavailable')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when no results and no error', () => {
|
||||
it('should show select to navigate in commands mode', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={true}
|
||||
hasQuery={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.selectToNavigate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show searching when has query', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.searching')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show start typing when no query', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.startTyping')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('right content', () => {
|
||||
describe('when there are results or error', () => {
|
||||
it('should show clear to search all when in specific mode', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={5}
|
||||
searchMode="@app"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.clearToSearchAll')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show use @ for specific when in general mode', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={5}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.useAtForSpecific')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show same hint when error', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={true}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.useAtForSpecific')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when no results and no error', () => {
|
||||
it('should show tips when has query', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.tips')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show tips when in commands mode', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={true}
|
||||
hasQuery={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.tips')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show press ESC to close when no query and not in commands mode', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.pressEscToClose')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('styling', () => {
|
||||
it('should have border and background classes', () => {
|
||||
const { container } = render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
const footer = container.firstChild
|
||||
expect(footer).toHaveClass('border-t', 'border-divider-subtle', 'bg-components-panel-bg-blur')
|
||||
})
|
||||
|
||||
it('should have flex layout for content', () => {
|
||||
const { container } = render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
const flexContainer = container.querySelector('.flex.items-center.justify-between')
|
||||
expect(flexContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,90 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export type FooterProps = {
|
||||
resultCount: number
|
||||
searchMode: string
|
||||
isError: boolean
|
||||
isCommandsMode: boolean
|
||||
hasQuery: boolean
|
||||
}
|
||||
|
||||
const Footer: FC<FooterProps> = ({
|
||||
resultCount,
|
||||
searchMode,
|
||||
isError,
|
||||
isCommandsMode,
|
||||
hasQuery,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const renderLeftContent = () => {
|
||||
if (resultCount > 0 || isError) {
|
||||
if (isError) {
|
||||
return (
|
||||
<span className="text-red-500">
|
||||
{t('gotoAnything.someServicesUnavailable', { ns: 'app' })}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{t('gotoAnything.resultCount', { ns: 'app', count: resultCount })}
|
||||
{searchMode !== 'general' && (
|
||||
<span className="ml-2 opacity-60">
|
||||
{t('gotoAnything.inScope', { ns: 'app', scope: searchMode.replace('@', '') })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="opacity-60">
|
||||
{(() => {
|
||||
if (isCommandsMode)
|
||||
return t('gotoAnything.selectToNavigate', { ns: 'app' })
|
||||
|
||||
if (hasQuery)
|
||||
return t('gotoAnything.searching', { ns: 'app' })
|
||||
|
||||
return t('gotoAnything.startTyping', { ns: 'app' })
|
||||
})()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const renderRightContent = () => {
|
||||
if (resultCount > 0 || isError) {
|
||||
return (
|
||||
<span className="opacity-60">
|
||||
{searchMode !== 'general'
|
||||
? t('gotoAnything.clearToSearchAll', { ns: 'app' })
|
||||
: t('gotoAnything.useAtForSpecific', { ns: 'app' })}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="opacity-60">
|
||||
{hasQuery || isCommandsMode
|
||||
? t('gotoAnything.tips', { ns: 'app' })
|
||||
: t('gotoAnything.pressEscToClose', { ns: 'app' })}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-2 text-xs text-text-tertiary">
|
||||
<div className="flex min-h-[16px] items-center justify-between">
|
||||
<span>{renderLeftContent()}</span>
|
||||
{renderRightContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Footer
|
||||
@@ -1,14 +0,0 @@
|
||||
export { default as EmptyState } from './empty-state'
|
||||
export type { EmptyStateProps, EmptyStateVariant } from './empty-state'
|
||||
|
||||
export { default as Footer } from './footer'
|
||||
export type { FooterProps } from './footer'
|
||||
|
||||
export { default as ResultItem } from './result-item'
|
||||
export type { ResultItemProps } from './result-item'
|
||||
|
||||
export { default as ResultList } from './result-list'
|
||||
export type { ResultListProps } from './result-list'
|
||||
|
||||
export { default as SearchInput } from './search-input'
|
||||
export type { SearchInputProps } from './search-input'
|
||||
@@ -1,38 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { SearchResult } from '../actions/types'
|
||||
import { Command } from 'cmdk'
|
||||
|
||||
export type ResultItemProps = {
|
||||
result: SearchResult
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
const ResultItem: FC<ResultItemProps> = ({ result, onSelect }) => {
|
||||
return (
|
||||
<Command.Item
|
||||
key={`${result.type}-${result.id}`}
|
||||
value={`${result.type}-${result.id}`}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-md p-3 will-change-[background-color] hover:bg-state-base-hover aria-[selected=true]:bg-state-base-hover-alt data-[selected=true]:bg-state-base-hover-alt"
|
||||
onSelect={onSelect}
|
||||
>
|
||||
{result.icon}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-text-secondary">
|
||||
{result.title}
|
||||
</div>
|
||||
{result.description && (
|
||||
<div className="mt-0.5 truncate text-xs text-text-quaternary">
|
||||
{result.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs capitalize text-text-quaternary">
|
||||
{result.type}
|
||||
</div>
|
||||
</Command.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResultItem
|
||||
@@ -1,49 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { SearchResult } from '../actions/types'
|
||||
import { Command } from 'cmdk'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ResultItem from './result-item'
|
||||
|
||||
export type ResultListProps = {
|
||||
groupedResults: Record<string, SearchResult[]>
|
||||
onSelect: (result: SearchResult) => void
|
||||
}
|
||||
|
||||
const ResultList: FC<ResultListProps> = ({ groupedResults, onSelect }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const getGroupHeading = (type: string) => {
|
||||
const typeMap = {
|
||||
'app': 'gotoAnything.groups.apps',
|
||||
'plugin': 'gotoAnything.groups.plugins',
|
||||
'knowledge': 'gotoAnything.groups.knowledgeBases',
|
||||
'workflow-node': 'gotoAnything.groups.workflowNodes',
|
||||
'command': 'gotoAnything.groups.commands',
|
||||
} as const
|
||||
return t(typeMap[type as keyof typeof typeMap] || `${type}s`, { ns: 'app' })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.entries(groupedResults).map(([type, results]) => (
|
||||
<Command.Group
|
||||
key={type}
|
||||
heading={getGroupHeading(type)}
|
||||
className="p-2 capitalize text-text-secondary"
|
||||
>
|
||||
{results.map(result => (
|
||||
<ResultItem
|
||||
key={`${result.type}-${result.id}`}
|
||||
result={result}
|
||||
onSelect={() => onSelect(result)}
|
||||
/>
|
||||
))}
|
||||
</Command.Group>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResultList
|
||||
@@ -1,206 +0,0 @@
|
||||
import type { ChangeEvent, KeyboardEvent, RefObject } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SearchInput from './search-input'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => `${options?.ns || 'common'}.${key}`,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiSearchLine: ({ className }: { className?: string }) => (
|
||||
<svg data-testid="search-icon" className={className} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
|
||||
default: ({ keys, textColor }: { keys: string[], textColor: string }) => (
|
||||
<div data-testid="shortcuts-name" data-keys={keys.join(',')} data-color={textColor}>
|
||||
{keys.join('+')}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/input', async () => {
|
||||
const { forwardRef } = await import('react')
|
||||
|
||||
type MockInputProps = {
|
||||
value?: string
|
||||
placeholder?: string
|
||||
onChange?: (e: ChangeEvent<HTMLInputElement>) => void
|
||||
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void
|
||||
className?: string
|
||||
wrapperClassName?: string
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
const MockInput = forwardRef<HTMLInputElement, MockInputProps>(
|
||||
({ value, placeholder, onChange, onKeyDown, className, wrapperClassName, autoFocus }, ref) => (
|
||||
<input
|
||||
ref={ref}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
className={className}
|
||||
data-wrapper-class={wrapperClassName}
|
||||
autoFocus={autoFocus}
|
||||
data-testid="search-input"
|
||||
/>
|
||||
),
|
||||
)
|
||||
MockInput.displayName = 'MockInput'
|
||||
|
||||
return { default: MockInput }
|
||||
})
|
||||
|
||||
describe('SearchInput', () => {
|
||||
const defaultProps = {
|
||||
inputRef: { current: null } as RefObject<HTMLInputElement | null>,
|
||||
value: '',
|
||||
onChange: vi.fn(),
|
||||
searchMode: 'general',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render search icon', () => {
|
||||
render(<SearchInput {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('search-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render input field', () => {
|
||||
render(<SearchInput {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('search-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render shortcuts name', () => {
|
||||
render(<SearchInput {...defaultProps} />)
|
||||
|
||||
const shortcuts = screen.getByTestId('shortcuts-name')
|
||||
expect(shortcuts).toBeInTheDocument()
|
||||
expect(shortcuts).toHaveAttribute('data-keys', 'ctrl,K')
|
||||
expect(shortcuts).toHaveAttribute('data-color', 'secondary')
|
||||
})
|
||||
|
||||
it('should use provided placeholder', () => {
|
||||
render(<SearchInput {...defaultProps} placeholder="Custom placeholder" />)
|
||||
|
||||
expect(screen.getByPlaceholderText('Custom placeholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use default placeholder from translation', () => {
|
||||
render(<SearchInput {...defaultProps} />)
|
||||
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mode label', () => {
|
||||
it('should NOT show mode badge in general mode', () => {
|
||||
render(<SearchInput {...defaultProps} searchMode="general" />)
|
||||
|
||||
expect(screen.queryByText('GENERAL')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show SCOPES label in scopes mode', () => {
|
||||
render(<SearchInput {...defaultProps} searchMode="scopes" />)
|
||||
|
||||
expect(screen.getByText('SCOPES')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show COMMANDS label in commands mode', () => {
|
||||
render(<SearchInput {...defaultProps} searchMode="commands" />)
|
||||
|
||||
expect(screen.getByText('COMMANDS')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show APP label in @app mode', () => {
|
||||
render(<SearchInput {...defaultProps} searchMode="@app" />)
|
||||
|
||||
expect(screen.getByText('APP')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show PLUGIN label in @plugin mode', () => {
|
||||
render(<SearchInput {...defaultProps} searchMode="@plugin" />)
|
||||
|
||||
expect(screen.getByText('PLUGIN')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show KNOWLEDGE label in @knowledge mode', () => {
|
||||
render(<SearchInput {...defaultProps} searchMode="@knowledge" />)
|
||||
|
||||
expect(screen.getByText('KNOWLEDGE')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show NODE label in @node mode', () => {
|
||||
render(<SearchInput {...defaultProps} searchMode="@node" />)
|
||||
|
||||
expect(screen.getByText('NODE')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should uppercase custom mode label', () => {
|
||||
render(<SearchInput {...defaultProps} searchMode="@custom" />)
|
||||
|
||||
expect(screen.getByText('CUSTOM')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('input interactions', () => {
|
||||
it('should call onChange when typing', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SearchInput {...defaultProps} onChange={onChange} />)
|
||||
|
||||
const input = screen.getByTestId('search-input')
|
||||
fireEvent.change(input, { target: { value: 'test query' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('test query')
|
||||
})
|
||||
|
||||
it('should call onKeyDown when pressing keys', () => {
|
||||
const onKeyDown = vi.fn()
|
||||
render(<SearchInput {...defaultProps} onKeyDown={onKeyDown} />)
|
||||
|
||||
const input = screen.getByTestId('search-input')
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(onKeyDown).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render with provided value', () => {
|
||||
render(<SearchInput {...defaultProps} value="existing query" />)
|
||||
|
||||
expect(screen.getByDisplayValue('existing query')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT throw when onKeyDown is undefined', () => {
|
||||
render(<SearchInput {...defaultProps} onKeyDown={undefined} />)
|
||||
|
||||
const input = screen.getByTestId('search-input')
|
||||
expect(() => fireEvent.keyDown(input, { key: 'Enter' })).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('styling', () => {
|
||||
it('should have search icon styling', () => {
|
||||
render(<SearchInput {...defaultProps} />)
|
||||
|
||||
const icon = screen.getByTestId('search-icon')
|
||||
expect(icon).toHaveClass('h-4', 'w-4', 'text-text-quaternary')
|
||||
})
|
||||
|
||||
it('should have mode badge styling when visible', () => {
|
||||
const { container } = render(<SearchInput {...defaultProps} searchMode="@app" />)
|
||||
|
||||
const badge = container.querySelector('.bg-gray-100')
|
||||
expect(badge).toBeInTheDocument()
|
||||
expect(badge).toHaveClass('rounded', 'px-2', 'text-xs', 'font-medium')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,62 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { FC, KeyboardEvent, RefObject } from 'react'
|
||||
import { RiSearchLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
|
||||
export type SearchInputProps = {
|
||||
inputRef: RefObject<HTMLInputElement | null>
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void
|
||||
searchMode: string
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const SearchInput: FC<SearchInputProps> = ({
|
||||
inputRef,
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
searchMode,
|
||||
placeholder,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const getModeLabel = () => {
|
||||
if (searchMode === 'scopes')
|
||||
return 'SCOPES'
|
||||
else if (searchMode === 'commands')
|
||||
return 'COMMANDS'
|
||||
else
|
||||
return searchMode.replace('@', '').toUpperCase()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-divider-subtle bg-components-panel-bg-blur px-4 py-3">
|
||||
<RiSearchLine className="h-4 w-4 text-text-quaternary" />
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
placeholder={placeholder || t('gotoAnything.searchPlaceholder', { ns: 'app' })}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
className="flex-1 !border-0 !bg-transparent !shadow-none"
|
||||
wrapperClassName="flex-1 !border-0 !bg-transparent"
|
||||
autoFocus
|
||||
/>
|
||||
{searchMode !== 'general' && (
|
||||
<div className="flex items-center gap-1 rounded bg-gray-100 px-2 py-[2px] text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
<span>{getModeLabel()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ShortcutsName keys={['ctrl', 'K']} textColor="secondary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchInput
|
||||
@@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { GotoAnythingProvider, useGotoAnythingContext } from './context'
|
||||
|
||||
let pathnameMock: string | null | undefined = '/'
|
||||
let pathnameMock = '/'
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => pathnameMock,
|
||||
}))
|
||||
@@ -57,79 +57,4 @@ describe('GotoAnythingProvider', () => {
|
||||
expect(screen.getByTestId('status')).toHaveTextContent('false|true')
|
||||
})
|
||||
})
|
||||
|
||||
it('should set both flags to false when pathname is null', async () => {
|
||||
pathnameMock = null
|
||||
|
||||
render(
|
||||
<GotoAnythingProvider>
|
||||
<ContextConsumer />
|
||||
</GotoAnythingProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('status')).toHaveTextContent('false|false')
|
||||
})
|
||||
})
|
||||
|
||||
it('should set both flags to false when pathname is undefined', async () => {
|
||||
pathnameMock = undefined
|
||||
|
||||
render(
|
||||
<GotoAnythingProvider>
|
||||
<ContextConsumer />
|
||||
</GotoAnythingProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('status')).toHaveTextContent('false|false')
|
||||
})
|
||||
})
|
||||
|
||||
it('should set both flags to false for regular paths', async () => {
|
||||
pathnameMock = '/apps'
|
||||
|
||||
render(
|
||||
<GotoAnythingProvider>
|
||||
<ContextConsumer />
|
||||
</GotoAnythingProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('status')).toHaveTextContent('false|false')
|
||||
})
|
||||
})
|
||||
|
||||
it('should NOT match non-pipeline dataset paths', async () => {
|
||||
pathnameMock = '/datasets/abc/documents'
|
||||
|
||||
render(
|
||||
<GotoAnythingProvider>
|
||||
<ContextConsumer />
|
||||
</GotoAnythingProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('status')).toHaveTextContent('false|false')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useGotoAnythingContext', () => {
|
||||
it('should return default values when used outside provider', () => {
|
||||
const TestComponent = () => {
|
||||
const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext()
|
||||
return (
|
||||
<div data-testid="context">
|
||||
{String(isWorkflowPage)}
|
||||
|
|
||||
{String(isRagPipelinePage)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<TestComponent />)
|
||||
|
||||
expect(screen.getByTestId('context')).toHaveTextContent('false|false')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export { useGotoAnythingModal } from './use-goto-anything-modal'
|
||||
export type { UseGotoAnythingModalReturn } from './use-goto-anything-modal'
|
||||
|
||||
export { useGotoAnythingNavigation } from './use-goto-anything-navigation'
|
||||
export type { UseGotoAnythingNavigationOptions, UseGotoAnythingNavigationReturn } from './use-goto-anything-navigation'
|
||||
|
||||
export { useGotoAnythingResults } from './use-goto-anything-results'
|
||||
export type { UseGotoAnythingResultsOptions, UseGotoAnythingResultsReturn } from './use-goto-anything-results'
|
||||
|
||||
export { useGotoAnythingSearch } from './use-goto-anything-search'
|
||||
export type { UseGotoAnythingSearchReturn } from './use-goto-anything-search'
|
||||
@@ -1,291 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useGotoAnythingModal } from './use-goto-anything-modal'
|
||||
|
||||
type KeyPressEvent = {
|
||||
preventDefault: () => void
|
||||
target?: EventTarget
|
||||
}
|
||||
|
||||
const keyPressHandlers: Record<string, (event: KeyPressEvent) => void> = {}
|
||||
let mockIsEventTargetInputArea = false
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useKeyPress: (keys: string | string[], handler: (event: KeyPressEvent) => void) => {
|
||||
const keyList = Array.isArray(keys) ? keys : [keys]
|
||||
keyList.forEach((key) => {
|
||||
keyPressHandlers[key] = handler
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils/common', () => ({
|
||||
getKeyboardKeyCodeBySystem: () => 'ctrl',
|
||||
isEventTargetInputArea: () => mockIsEventTargetInputArea,
|
||||
}))
|
||||
|
||||
describe('useGotoAnythingModal', () => {
|
||||
beforeEach(() => {
|
||||
Object.keys(keyPressHandlers).forEach(key => delete keyPressHandlers[key])
|
||||
mockIsEventTargetInputArea = false
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with show=false', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
expect(result.current.show).toBe(false)
|
||||
})
|
||||
|
||||
it('should provide inputRef initialized to null', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
expect(result.current.inputRef).toBeDefined()
|
||||
expect(result.current.inputRef.current).toBe(null)
|
||||
})
|
||||
|
||||
it('should provide setShow function', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
expect(typeof result.current.setShow).toBe('function')
|
||||
})
|
||||
|
||||
it('should provide handleClose function', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
expect(typeof result.current.handleClose).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('keyboard shortcuts', () => {
|
||||
it('should toggle show state when Ctrl+K is triggered', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
|
||||
expect(result.current.show).toBe(false)
|
||||
|
||||
act(() => {
|
||||
keyPressHandlers['ctrl.k']?.({ preventDefault: vi.fn(), target: document.body })
|
||||
})
|
||||
|
||||
expect(result.current.show).toBe(true)
|
||||
})
|
||||
|
||||
it('should toggle back to closed when Ctrl+K is triggered twice', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
|
||||
act(() => {
|
||||
keyPressHandlers['ctrl.k']?.({ preventDefault: vi.fn(), target: document.body })
|
||||
})
|
||||
expect(result.current.show).toBe(true)
|
||||
|
||||
act(() => {
|
||||
keyPressHandlers['ctrl.k']?.({ preventDefault: vi.fn(), target: document.body })
|
||||
})
|
||||
expect(result.current.show).toBe(false)
|
||||
})
|
||||
|
||||
it('should NOT toggle when focus is in input area and modal is closed', () => {
|
||||
mockIsEventTargetInputArea = true
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
|
||||
expect(result.current.show).toBe(false)
|
||||
|
||||
act(() => {
|
||||
keyPressHandlers['ctrl.k']?.({ preventDefault: vi.fn(), target: document.body })
|
||||
})
|
||||
|
||||
// Should remain closed because focus is in input area
|
||||
expect(result.current.show).toBe(false)
|
||||
})
|
||||
|
||||
it('should close modal when escape is pressed and modal is open', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
|
||||
// Open modal first
|
||||
act(() => {
|
||||
result.current.setShow(true)
|
||||
})
|
||||
expect(result.current.show).toBe(true)
|
||||
|
||||
// Press escape
|
||||
act(() => {
|
||||
keyPressHandlers.esc?.({ preventDefault: vi.fn() })
|
||||
})
|
||||
|
||||
expect(result.current.show).toBe(false)
|
||||
})
|
||||
|
||||
it('should NOT do anything when escape is pressed and modal is already closed', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
|
||||
expect(result.current.show).toBe(false)
|
||||
|
||||
const preventDefaultMock = vi.fn()
|
||||
act(() => {
|
||||
keyPressHandlers.esc?.({ preventDefault: preventDefaultMock })
|
||||
})
|
||||
|
||||
// Should remain closed, and preventDefault should not be called
|
||||
expect(result.current.show).toBe(false)
|
||||
expect(preventDefaultMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call preventDefault when Ctrl+K is triggered', () => {
|
||||
renderHook(() => useGotoAnythingModal())
|
||||
|
||||
const preventDefaultMock = vi.fn()
|
||||
act(() => {
|
||||
keyPressHandlers['ctrl.k']?.({ preventDefault: preventDefaultMock, target: document.body })
|
||||
})
|
||||
|
||||
expect(preventDefaultMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleClose', () => {
|
||||
it('should close modal when handleClose is called', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
|
||||
// Open modal first
|
||||
act(() => {
|
||||
result.current.setShow(true)
|
||||
})
|
||||
expect(result.current.show).toBe(true)
|
||||
|
||||
// Close via handleClose
|
||||
act(() => {
|
||||
result.current.handleClose()
|
||||
})
|
||||
|
||||
expect(result.current.show).toBe(false)
|
||||
})
|
||||
|
||||
it('should be safe to call handleClose when modal is already closed', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
|
||||
expect(result.current.show).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.handleClose()
|
||||
})
|
||||
|
||||
expect(result.current.show).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setShow', () => {
|
||||
it('should accept boolean value', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
|
||||
act(() => {
|
||||
result.current.setShow(true)
|
||||
})
|
||||
expect(result.current.show).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.setShow(false)
|
||||
})
|
||||
expect(result.current.show).toBe(false)
|
||||
})
|
||||
|
||||
it('should accept function value', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
|
||||
act(() => {
|
||||
result.current.setShow(prev => !prev)
|
||||
})
|
||||
expect(result.current.show).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.setShow(prev => !prev)
|
||||
})
|
||||
expect(result.current.show).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('focus management', () => {
|
||||
it('should call requestAnimationFrame when modal opens', () => {
|
||||
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
|
||||
act(() => {
|
||||
result.current.setShow(true)
|
||||
})
|
||||
|
||||
expect(rafSpy).toHaveBeenCalled()
|
||||
rafSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should not call requestAnimationFrame when modal closes', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
|
||||
// First open
|
||||
act(() => {
|
||||
result.current.setShow(true)
|
||||
})
|
||||
|
||||
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
|
||||
|
||||
// Then close
|
||||
act(() => {
|
||||
result.current.setShow(false)
|
||||
})
|
||||
|
||||
expect(rafSpy).not.toHaveBeenCalled()
|
||||
rafSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should focus input when modal opens and inputRef.current exists', () => {
|
||||
// Mock requestAnimationFrame to execute callback immediately
|
||||
const originalRAF = window.requestAnimationFrame
|
||||
window.requestAnimationFrame = (callback: FrameRequestCallback) => {
|
||||
callback(0)
|
||||
return 0
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
|
||||
// Create a mock input element with focus method
|
||||
const mockFocus = vi.fn()
|
||||
const mockInput = { focus: mockFocus } as unknown as HTMLInputElement
|
||||
|
||||
// Manually set the inputRef
|
||||
Object.defineProperty(result.current.inputRef, 'current', {
|
||||
value: mockInput,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setShow(true)
|
||||
})
|
||||
|
||||
expect(mockFocus).toHaveBeenCalled()
|
||||
|
||||
// Restore original requestAnimationFrame
|
||||
window.requestAnimationFrame = originalRAF
|
||||
})
|
||||
|
||||
it('should not throw when inputRef.current is null when modal opens', () => {
|
||||
// Mock requestAnimationFrame to execute callback immediately
|
||||
const originalRAF = window.requestAnimationFrame
|
||||
window.requestAnimationFrame = (callback: FrameRequestCallback) => {
|
||||
callback(0)
|
||||
return 0
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
|
||||
// inputRef.current is already null by default
|
||||
|
||||
// Should not throw
|
||||
act(() => {
|
||||
result.current.setShow(true)
|
||||
})
|
||||
|
||||
expect(result.current.show).toBe(true)
|
||||
|
||||
// Restore original requestAnimationFrame
|
||||
window.requestAnimationFrame = originalRAF
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { getKeyboardKeyCodeBySystem, isEventTargetInputArea } from '@/app/components/workflow/utils/common'
|
||||
|
||||
export type UseGotoAnythingModalReturn = {
|
||||
show: boolean
|
||||
setShow: (show: boolean | ((prev: boolean) => boolean)) => void
|
||||
inputRef: RefObject<HTMLInputElement | null>
|
||||
handleClose: () => void
|
||||
}
|
||||
|
||||
export const useGotoAnythingModal = (): UseGotoAnythingModalReturn => {
|
||||
const [show, setShow] = useState<boolean>(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleToggleModal = useCallback((e: KeyboardEvent) => {
|
||||
// Allow closing when modal is open, even if focus is in the search input
|
||||
if (!show && isEventTargetInputArea(e.target as HTMLElement))
|
||||
return
|
||||
e.preventDefault()
|
||||
setShow(prev => !prev)
|
||||
}, [show])
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.k`, handleToggleModal, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress(['esc'], (e) => {
|
||||
if (show) {
|
||||
e.preventDefault()
|
||||
setShow(false)
|
||||
}
|
||||
})
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setShow(false)
|
||||
}, [])
|
||||
|
||||
// Focus input when modal opens
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus()
|
||||
})
|
||||
}
|
||||
}, [show])
|
||||
|
||||
return {
|
||||
show,
|
||||
setShow,
|
||||
inputRef,
|
||||
handleClose,
|
||||
}
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
import type * as React from 'react'
|
||||
import type { Plugin } from '../../plugins/types'
|
||||
import type { CommonNodeType } from '../../workflow/types'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import type { App } from '@/types/app'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useGotoAnythingNavigation } from './use-goto-anything-navigation'
|
||||
|
||||
const mockRouterPush = vi.fn()
|
||||
const mockSelectWorkflowNode = vi.fn()
|
||||
|
||||
type MockCommandResult = {
|
||||
mode: string
|
||||
execute?: () => void
|
||||
} | null
|
||||
|
||||
let mockFindCommandResult: MockCommandResult = null
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils/node-navigation', () => ({
|
||||
selectWorkflowNode: (...args: unknown[]) => mockSelectWorkflowNode(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../actions/commands/registry', () => ({
|
||||
slashCommandRegistry: {
|
||||
findCommand: () => mockFindCommandResult,
|
||||
},
|
||||
}))
|
||||
|
||||
const createMockActionItem = (
|
||||
key: '@app' | '@knowledge' | '@plugin' | '@node' | '/',
|
||||
extra: Record<string, unknown> = {},
|
||||
) => ({
|
||||
key,
|
||||
shortcut: key,
|
||||
title: `${key} title`,
|
||||
description: `${key} description`,
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
...extra,
|
||||
})
|
||||
|
||||
const createMockOptions = (overrides = {}) => ({
|
||||
Actions: {
|
||||
slash: createMockActionItem('/', { action: vi.fn() }),
|
||||
app: createMockActionItem('@app'),
|
||||
},
|
||||
setSearchQuery: vi.fn(),
|
||||
clearSelection: vi.fn(),
|
||||
inputRef: { current: { focus: vi.fn() } } as unknown as React.RefObject<HTMLInputElement>,
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('useGotoAnythingNavigation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFindCommandResult = null
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should return handleCommandSelect function', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions()))
|
||||
expect(typeof result.current.handleCommandSelect).toBe('function')
|
||||
})
|
||||
|
||||
it('should return handleNavigate function', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions()))
|
||||
expect(typeof result.current.handleNavigate).toBe('function')
|
||||
})
|
||||
|
||||
it('should initialize activePlugin as undefined', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions()))
|
||||
expect(result.current.activePlugin).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return setActivePlugin function', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions()))
|
||||
expect(typeof result.current.setActivePlugin).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleCommandSelect', () => {
|
||||
it('should execute direct mode slash command immediately', () => {
|
||||
const execute = vi.fn()
|
||||
mockFindCommandResult = { mode: 'direct', execute }
|
||||
const options = createMockOptions()
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(options))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCommandSelect('/theme')
|
||||
})
|
||||
|
||||
expect(execute).toHaveBeenCalled()
|
||||
expect(options.onClose).toHaveBeenCalled()
|
||||
expect(options.setSearchQuery).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('should NOT execute when handler has no execute function', () => {
|
||||
mockFindCommandResult = { mode: 'direct', execute: undefined }
|
||||
const options = createMockOptions()
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(options))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCommandSelect('/theme')
|
||||
})
|
||||
|
||||
expect(options.onClose).not.toHaveBeenCalled()
|
||||
// Should proceed with submenu mode
|
||||
expect(options.setSearchQuery).toHaveBeenCalledWith('/theme ')
|
||||
})
|
||||
|
||||
it('should proceed with submenu mode for non-direct commands', () => {
|
||||
mockFindCommandResult = { mode: 'submenu' }
|
||||
const options = createMockOptions()
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(options))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCommandSelect('/language')
|
||||
})
|
||||
|
||||
expect(options.setSearchQuery).toHaveBeenCalledWith('/language ')
|
||||
expect(options.clearSelection).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle @ commands (scopes)', () => {
|
||||
const options = createMockOptions()
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(options))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCommandSelect('@app')
|
||||
})
|
||||
|
||||
expect(options.setSearchQuery).toHaveBeenCalledWith('@app ')
|
||||
expect(options.clearSelection).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should focus input after setting search query', () => {
|
||||
const focusMock = vi.fn()
|
||||
const options = createMockOptions({
|
||||
inputRef: { current: { focus: focusMock } },
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(options))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCommandSelect('@app')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
expect(focusMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle null handler from registry', () => {
|
||||
mockFindCommandResult = null
|
||||
const options = createMockOptions()
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(options))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCommandSelect('/unknown')
|
||||
})
|
||||
|
||||
// Should proceed with submenu mode
|
||||
expect(options.setSearchQuery).toHaveBeenCalledWith('/unknown ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleNavigate', () => {
|
||||
it('should navigate to path for default result types', () => {
|
||||
const options = createMockOptions()
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(options))
|
||||
|
||||
act(() => {
|
||||
result.current.handleNavigate({
|
||||
id: '1',
|
||||
type: 'app' as const,
|
||||
title: 'My App',
|
||||
path: '/apps/1',
|
||||
data: { id: '1', name: 'My App' } as unknown as App,
|
||||
})
|
||||
})
|
||||
|
||||
expect(options.onClose).toHaveBeenCalled()
|
||||
expect(options.setSearchQuery).toHaveBeenCalledWith('')
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/apps/1')
|
||||
})
|
||||
|
||||
it('should NOT call router.push when path is empty', () => {
|
||||
const options = createMockOptions()
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(options))
|
||||
|
||||
act(() => {
|
||||
result.current.handleNavigate({
|
||||
id: '1',
|
||||
type: 'app' as const,
|
||||
title: 'My App',
|
||||
path: '',
|
||||
data: { id: '1', name: 'My App' } as unknown as App,
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockRouterPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should execute slash command action for command type', () => {
|
||||
const actionMock = vi.fn()
|
||||
const options = createMockOptions({
|
||||
Actions: {
|
||||
slash: { key: '/', shortcut: '/', action: actionMock },
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(options))
|
||||
|
||||
const commandResult = {
|
||||
id: 'cmd-1',
|
||||
type: 'command' as const,
|
||||
title: 'Theme Dark',
|
||||
data: { command: 'theme.set', args: { theme: 'dark' } },
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.handleNavigate(commandResult)
|
||||
})
|
||||
|
||||
expect(actionMock).toHaveBeenCalledWith(commandResult)
|
||||
})
|
||||
|
||||
it('should set activePlugin for plugin type', () => {
|
||||
const options = createMockOptions()
|
||||
const pluginData = { name: 'My Plugin', latest_package_identifier: 'pkg' } as unknown as Plugin
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(options))
|
||||
|
||||
act(() => {
|
||||
result.current.handleNavigate({
|
||||
id: 'plugin-1',
|
||||
type: 'plugin' as const,
|
||||
title: 'My Plugin',
|
||||
data: pluginData,
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.activePlugin).toEqual(pluginData)
|
||||
})
|
||||
|
||||
it('should select workflow node for workflow-node type', () => {
|
||||
const options = createMockOptions()
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(options))
|
||||
|
||||
act(() => {
|
||||
result.current.handleNavigate({
|
||||
id: 'node-1',
|
||||
type: 'workflow-node' as const,
|
||||
title: 'Start Node',
|
||||
metadata: { nodeId: 'node-123', nodeData: {} as CommonNodeType },
|
||||
data: { id: 'node-1' } as unknown as CommonNodeType,
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockSelectWorkflowNode).toHaveBeenCalledWith('node-123', true)
|
||||
})
|
||||
|
||||
it('should NOT select workflow node when metadata.nodeId is missing', () => {
|
||||
const options = createMockOptions()
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(options))
|
||||
|
||||
act(() => {
|
||||
result.current.handleNavigate({
|
||||
id: 'node-1',
|
||||
type: 'workflow-node' as const,
|
||||
title: 'Start Node',
|
||||
metadata: undefined,
|
||||
data: { id: 'node-1' } as unknown as CommonNodeType,
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockSelectWorkflowNode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle knowledge type (default case with path)', () => {
|
||||
const options = createMockOptions()
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(options))
|
||||
|
||||
act(() => {
|
||||
result.current.handleNavigate({
|
||||
id: 'kb-1',
|
||||
type: 'knowledge' as const,
|
||||
title: 'My Knowledge Base',
|
||||
path: '/datasets/kb-1',
|
||||
data: { id: 'kb-1', name: 'My Knowledge Base' } as unknown as DataSet,
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/datasets/kb-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setActivePlugin', () => {
|
||||
it('should update activePlugin state', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions()))
|
||||
|
||||
const plugin = { name: 'Test Plugin', latest_package_identifier: 'test-pkg' } as unknown as Plugin
|
||||
act(() => {
|
||||
result.current.setActivePlugin(plugin)
|
||||
})
|
||||
|
||||
expect(result.current.activePlugin).toEqual(plugin)
|
||||
})
|
||||
|
||||
it('should clear activePlugin when set to undefined', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions()))
|
||||
|
||||
// First set a plugin
|
||||
act(() => {
|
||||
result.current.setActivePlugin({ name: 'Plugin', latest_package_identifier: 'pkg' } as unknown as Plugin)
|
||||
})
|
||||
expect(result.current.activePlugin).toBeDefined()
|
||||
|
||||
// Then clear it
|
||||
act(() => {
|
||||
result.current.setActivePlugin(undefined)
|
||||
})
|
||||
|
||||
expect(result.current.activePlugin).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle undefined inputRef.current', () => {
|
||||
const options = createMockOptions({
|
||||
inputRef: { current: null },
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(options))
|
||||
|
||||
// Should not throw
|
||||
act(() => {
|
||||
result.current.handleCommandSelect('@app')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
// No error should occur
|
||||
})
|
||||
|
||||
it('should handle missing slash action', () => {
|
||||
const options = createMockOptions({
|
||||
Actions: {},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingNavigation(options))
|
||||
|
||||
// Should not throw
|
||||
act(() => {
|
||||
result.current.handleNavigate({
|
||||
id: 'cmd-1',
|
||||
type: 'command' as const,
|
||||
title: 'Command',
|
||||
data: { command: 'test-command' },
|
||||
})
|
||||
})
|
||||
|
||||
// No error should occur
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,96 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import type { Plugin } from '../../plugins/types'
|
||||
import type { ActionItem, SearchResult } from '../actions/types'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation'
|
||||
import { slashCommandRegistry } from '../actions/commands/registry'
|
||||
|
||||
export type UseGotoAnythingNavigationReturn = {
|
||||
handleCommandSelect: (commandKey: string) => void
|
||||
handleNavigate: (result: SearchResult) => void
|
||||
activePlugin: Plugin | undefined
|
||||
setActivePlugin: (plugin: Plugin | undefined) => void
|
||||
}
|
||||
|
||||
export type UseGotoAnythingNavigationOptions = {
|
||||
Actions: Record<string, ActionItem>
|
||||
setSearchQuery: (query: string) => void
|
||||
clearSelection: () => void
|
||||
inputRef: RefObject<HTMLInputElement | null>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const useGotoAnythingNavigation = (
|
||||
options: UseGotoAnythingNavigationOptions,
|
||||
): UseGotoAnythingNavigationReturn => {
|
||||
const {
|
||||
Actions,
|
||||
setSearchQuery,
|
||||
clearSelection,
|
||||
inputRef,
|
||||
onClose,
|
||||
} = options
|
||||
|
||||
const router = useRouter()
|
||||
const [activePlugin, setActivePlugin] = useState<Plugin>()
|
||||
|
||||
const handleCommandSelect = useCallback((commandKey: string) => {
|
||||
// Check if it's a slash command
|
||||
if (commandKey.startsWith('/')) {
|
||||
const commandName = commandKey.substring(1)
|
||||
const handler = slashCommandRegistry.findCommand(commandName)
|
||||
|
||||
// If it's a direct mode command, execute immediately
|
||||
if (handler?.mode === 'direct' && handler.execute) {
|
||||
handler.execute()
|
||||
onClose()
|
||||
setSearchQuery('')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, proceed with the normal flow (submenu mode)
|
||||
setSearchQuery(`${commandKey} `)
|
||||
clearSelection()
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus()
|
||||
}, 0)
|
||||
}, [onClose, setSearchQuery, clearSelection, inputRef])
|
||||
|
||||
// Handle navigation to selected result
|
||||
const handleNavigate = useCallback((result: SearchResult) => {
|
||||
onClose()
|
||||
setSearchQuery('')
|
||||
|
||||
switch (result.type) {
|
||||
case 'command': {
|
||||
// Execute slash commands
|
||||
const action = Actions.slash
|
||||
action?.action?.(result)
|
||||
break
|
||||
}
|
||||
case 'plugin':
|
||||
setActivePlugin(result.data)
|
||||
break
|
||||
case 'workflow-node':
|
||||
// Handle workflow node selection and navigation
|
||||
if (result.metadata?.nodeId)
|
||||
selectWorkflowNode(result.metadata.nodeId, true)
|
||||
|
||||
break
|
||||
default:
|
||||
if (result.path)
|
||||
router.push(result.path)
|
||||
}
|
||||
}, [router, Actions, onClose, setSearchQuery])
|
||||
|
||||
return {
|
||||
handleCommandSelect,
|
||||
handleNavigate,
|
||||
activePlugin,
|
||||
setActivePlugin,
|
||||
}
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
import type { SearchResult } from '../actions/types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useGotoAnythingResults } from './use-goto-anything-results'
|
||||
|
||||
type MockQueryResult = {
|
||||
data: Array<{ id: string, type: string, title: string }> | undefined
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
type UseQueryOptions = {
|
||||
queryFn: () => Promise<SearchResult[]>
|
||||
}
|
||||
|
||||
let mockQueryResult: MockQueryResult = { data: [], isLoading: false, isError: false, error: null }
|
||||
let capturedQueryFn: (() => Promise<SearchResult[]>) | null = null
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: (options: UseQueryOptions) => {
|
||||
capturedQueryFn = options.queryFn
|
||||
return mockQueryResult
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
const mockMatchAction = vi.fn()
|
||||
const mockSearchAnything = vi.fn()
|
||||
|
||||
vi.mock('../actions', () => ({
|
||||
matchAction: (...args: unknown[]) => mockMatchAction(...args),
|
||||
searchAnything: (...args: unknown[]) => mockSearchAnything(...args),
|
||||
}))
|
||||
|
||||
const createMockActionItem = (key: '@app' | '@knowledge' | '@plugin' | '@node' | '/') => ({
|
||||
key,
|
||||
shortcut: key,
|
||||
title: `${key} title`,
|
||||
description: `${key} description`,
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
})
|
||||
|
||||
const createMockOptions = (overrides = {}) => ({
|
||||
searchQueryDebouncedValue: '',
|
||||
searchMode: 'general',
|
||||
isCommandsMode: false,
|
||||
Actions: { app: createMockActionItem('@app') },
|
||||
isWorkflowPage: false,
|
||||
isRagPipelinePage: false,
|
||||
cmdVal: '_',
|
||||
setCmdVal: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('useGotoAnythingResults', () => {
|
||||
beforeEach(() => {
|
||||
mockQueryResult = { data: [], isLoading: false, isError: false, error: null }
|
||||
capturedQueryFn = null
|
||||
mockMatchAction.mockReset()
|
||||
mockSearchAnything.mockReset()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should return empty arrays when no results', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
|
||||
|
||||
expect(result.current.searchResults).toEqual([])
|
||||
expect(result.current.dedupedResults).toEqual([])
|
||||
expect(result.current.groupedResults).toEqual({})
|
||||
})
|
||||
|
||||
it('should return loading state', () => {
|
||||
mockQueryResult = { data: [], isLoading: true, isError: false, error: null }
|
||||
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
|
||||
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
})
|
||||
|
||||
it('should return error state', () => {
|
||||
const error = new Error('Test error')
|
||||
mockQueryResult = { data: [], isLoading: false, isError: true, error }
|
||||
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
|
||||
|
||||
expect(result.current.isError).toBe(true)
|
||||
expect(result.current.error).toBe(error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dedupedResults', () => {
|
||||
it('should remove duplicate results', () => {
|
||||
mockQueryResult = {
|
||||
data: [
|
||||
{ id: '1', type: 'app', title: 'App 1' },
|
||||
{ id: '1', type: 'app', title: 'App 1 Duplicate' },
|
||||
{ id: '2', type: 'app', title: 'App 2' },
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
|
||||
|
||||
expect(result.current.dedupedResults).toHaveLength(2)
|
||||
expect(result.current.dedupedResults[0].id).toBe('1')
|
||||
expect(result.current.dedupedResults[1].id).toBe('2')
|
||||
})
|
||||
|
||||
it('should keep first occurrence when duplicates exist', () => {
|
||||
mockQueryResult = {
|
||||
data: [
|
||||
{ id: '1', type: 'app', title: 'First' },
|
||||
{ id: '1', type: 'app', title: 'Second' },
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
|
||||
|
||||
expect(result.current.dedupedResults).toHaveLength(1)
|
||||
expect(result.current.dedupedResults[0].title).toBe('First')
|
||||
})
|
||||
|
||||
it('should handle different types with same id', () => {
|
||||
mockQueryResult = {
|
||||
data: [
|
||||
{ id: '1', type: 'app', title: 'App' },
|
||||
{ id: '1', type: 'plugin', title: 'Plugin' },
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
|
||||
|
||||
// Different types, same id = different keys, so both should remain
|
||||
expect(result.current.dedupedResults).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupedResults', () => {
|
||||
it('should group results by type', () => {
|
||||
mockQueryResult = {
|
||||
data: [
|
||||
{ id: '1', type: 'app', title: 'App 1' },
|
||||
{ id: '2', type: 'app', title: 'App 2' },
|
||||
{ id: '3', type: 'plugin', title: 'Plugin 1' },
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
|
||||
|
||||
expect(result.current.groupedResults.app).toHaveLength(2)
|
||||
expect(result.current.groupedResults.plugin).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should handle single type', () => {
|
||||
mockQueryResult = {
|
||||
data: [
|
||||
{ id: '1', type: 'knowledge', title: 'KB 1' },
|
||||
{ id: '2', type: 'knowledge', title: 'KB 2' },
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
|
||||
|
||||
expect(Object.keys(result.current.groupedResults)).toEqual(['knowledge'])
|
||||
expect(result.current.groupedResults.knowledge).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should return empty object when no results', () => {
|
||||
mockQueryResult = { data: [], isLoading: false, isError: false, error: null }
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
|
||||
|
||||
expect(result.current.groupedResults).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('auto-select first result', () => {
|
||||
it('should call setCmdVal when results change and current value does not exist', () => {
|
||||
const setCmdVal = vi.fn()
|
||||
mockQueryResult = {
|
||||
data: [{ id: '1', type: 'app', title: 'App 1' }],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
renderHook(() => useGotoAnythingResults(createMockOptions({
|
||||
cmdVal: 'non-existent',
|
||||
setCmdVal,
|
||||
})))
|
||||
|
||||
expect(setCmdVal).toHaveBeenCalledWith('app-1')
|
||||
})
|
||||
|
||||
it('should NOT call setCmdVal when in commands mode', () => {
|
||||
const setCmdVal = vi.fn()
|
||||
mockQueryResult = {
|
||||
data: [{ id: '1', type: 'app', title: 'App 1' }],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
renderHook(() => useGotoAnythingResults(createMockOptions({
|
||||
isCommandsMode: true,
|
||||
setCmdVal,
|
||||
})))
|
||||
|
||||
expect(setCmdVal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT call setCmdVal when results are empty', () => {
|
||||
const setCmdVal = vi.fn()
|
||||
mockQueryResult = { data: [], isLoading: false, isError: false, error: null }
|
||||
|
||||
renderHook(() => useGotoAnythingResults(createMockOptions({
|
||||
setCmdVal,
|
||||
})))
|
||||
|
||||
expect(setCmdVal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT call setCmdVal when current value exists in results', () => {
|
||||
const setCmdVal = vi.fn()
|
||||
mockQueryResult = {
|
||||
data: [
|
||||
{ id: '1', type: 'app', title: 'App 1' },
|
||||
{ id: '2', type: 'app', title: 'App 2' },
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
renderHook(() => useGotoAnythingResults(createMockOptions({
|
||||
cmdVal: 'app-2',
|
||||
setCmdVal,
|
||||
})))
|
||||
|
||||
expect(setCmdVal).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should return error as Error | null', () => {
|
||||
const error = new Error('Search failed')
|
||||
mockQueryResult = { data: [], isLoading: false, isError: true, error }
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
|
||||
|
||||
expect(result.current.error).toBeInstanceOf(Error)
|
||||
expect(result.current.error?.message).toBe('Search failed')
|
||||
})
|
||||
|
||||
it('should return null error when no error', () => {
|
||||
mockQueryResult = { data: [], isLoading: false, isError: false, error: null }
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
|
||||
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchResults', () => {
|
||||
it('should return raw search results', () => {
|
||||
const mockData = [
|
||||
{ id: '1', type: 'app', title: 'App 1' },
|
||||
{ id: '2', type: 'plugin', title: 'Plugin 1' },
|
||||
]
|
||||
mockQueryResult = { data: mockData, isLoading: false, isError: false, error: null }
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
|
||||
|
||||
expect(result.current.searchResults).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('should default to empty array when data is undefined', () => {
|
||||
mockQueryResult = { data: undefined, isLoading: false, isError: false, error: null }
|
||||
|
||||
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
|
||||
|
||||
expect(result.current.searchResults).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryFn execution', () => {
|
||||
it('should call matchAction with lowercased query', async () => {
|
||||
const mockActions = { app: createMockActionItem('@app') }
|
||||
mockMatchAction.mockReturnValue({ key: '@app' })
|
||||
mockSearchAnything.mockResolvedValue([])
|
||||
|
||||
renderHook(() => useGotoAnythingResults(createMockOptions({
|
||||
searchQueryDebouncedValue: 'TEST QUERY',
|
||||
Actions: mockActions,
|
||||
})))
|
||||
|
||||
expect(capturedQueryFn).toBeDefined()
|
||||
await capturedQueryFn!()
|
||||
|
||||
expect(mockMatchAction).toHaveBeenCalledWith('test query', mockActions)
|
||||
})
|
||||
|
||||
it('should call searchAnything with correct parameters', async () => {
|
||||
const mockActions = { app: createMockActionItem('@app') }
|
||||
const mockAction = { key: '@app' }
|
||||
mockMatchAction.mockReturnValue(mockAction)
|
||||
mockSearchAnything.mockResolvedValue([{ id: '1', type: 'app', title: 'Result' }])
|
||||
|
||||
renderHook(() => useGotoAnythingResults(createMockOptions({
|
||||
searchQueryDebouncedValue: 'My Query',
|
||||
Actions: mockActions,
|
||||
})))
|
||||
|
||||
expect(capturedQueryFn).toBeDefined()
|
||||
const result = await capturedQueryFn!()
|
||||
|
||||
expect(mockSearchAnything).toHaveBeenCalledWith('en_US', 'my query', mockAction, mockActions)
|
||||
expect(result).toEqual([{ id: '1', type: 'app', title: 'Result' }])
|
||||
})
|
||||
|
||||
it('should handle searchAnything returning results', async () => {
|
||||
const expectedResults = [
|
||||
{ id: '1', type: 'app', title: 'App 1' },
|
||||
{ id: '2', type: 'plugin', title: 'Plugin 1' },
|
||||
]
|
||||
mockMatchAction.mockReturnValue(null)
|
||||
mockSearchAnything.mockResolvedValue(expectedResults)
|
||||
|
||||
renderHook(() => useGotoAnythingResults(createMockOptions({
|
||||
searchQueryDebouncedValue: 'search term',
|
||||
})))
|
||||
|
||||
expect(capturedQueryFn).toBeDefined()
|
||||
const result = await capturedQueryFn!()
|
||||
|
||||
expect(result).toEqual(expectedResults)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,115 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ActionItem, SearchResult } from '../actions/types'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import { matchAction, searchAnything } from '../actions'
|
||||
|
||||
export type UseGotoAnythingResultsReturn = {
|
||||
searchResults: SearchResult[]
|
||||
dedupedResults: SearchResult[]
|
||||
groupedResults: Record<string, SearchResult[]>
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export type UseGotoAnythingResultsOptions = {
|
||||
searchQueryDebouncedValue: string
|
||||
searchMode: string
|
||||
isCommandsMode: boolean
|
||||
Actions: Record<string, ActionItem>
|
||||
isWorkflowPage: boolean
|
||||
isRagPipelinePage: boolean
|
||||
cmdVal: string
|
||||
setCmdVal: (val: string) => void
|
||||
}
|
||||
|
||||
export const useGotoAnythingResults = (
|
||||
options: UseGotoAnythingResultsOptions,
|
||||
): UseGotoAnythingResultsReturn => {
|
||||
const {
|
||||
searchQueryDebouncedValue,
|
||||
searchMode,
|
||||
isCommandsMode,
|
||||
Actions,
|
||||
isWorkflowPage,
|
||||
isRagPipelinePage,
|
||||
cmdVal,
|
||||
setCmdVal,
|
||||
} = options
|
||||
|
||||
const defaultLocale = useGetLanguage()
|
||||
|
||||
// Use action keys as stable cache key instead of the full Actions object
|
||||
// (Actions contains functions which are not serializable)
|
||||
const actionKeys = useMemo(() => Object.keys(Actions).sort(), [Actions])
|
||||
|
||||
const { data: searchResults = [], isLoading, isError, error } = useQuery(
|
||||
{
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- Actions intentionally excluded: contains non-serializable functions; actionKeys provides stable representation
|
||||
queryKey: [
|
||||
'goto-anything',
|
||||
'search-result',
|
||||
searchQueryDebouncedValue,
|
||||
searchMode,
|
||||
isWorkflowPage,
|
||||
isRagPipelinePage,
|
||||
defaultLocale,
|
||||
actionKeys,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const query = searchQueryDebouncedValue.toLowerCase()
|
||||
const action = matchAction(query, Actions)
|
||||
return await searchAnything(defaultLocale, query, action, Actions)
|
||||
},
|
||||
enabled: !!searchQueryDebouncedValue && !isCommandsMode,
|
||||
staleTime: 30000,
|
||||
gcTime: 300000,
|
||||
},
|
||||
)
|
||||
|
||||
const dedupedResults = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
return searchResults.filter((result) => {
|
||||
const key = `${result.type}-${result.id}`
|
||||
if (seen.has(key))
|
||||
return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}, [searchResults])
|
||||
|
||||
// Group results by type
|
||||
const groupedResults = useMemo(() => dedupedResults.reduce((acc, result) => {
|
||||
if (!acc[result.type])
|
||||
acc[result.type] = []
|
||||
|
||||
acc[result.type].push(result)
|
||||
return acc
|
||||
}, {} as Record<string, SearchResult[]>), [dedupedResults])
|
||||
|
||||
// Auto-select first result when results change
|
||||
useEffect(() => {
|
||||
if (isCommandsMode)
|
||||
return
|
||||
|
||||
if (!dedupedResults.length)
|
||||
return
|
||||
|
||||
const currentValueExists = dedupedResults.some(result => `${result.type}-${result.id}` === cmdVal)
|
||||
|
||||
if (!currentValueExists)
|
||||
setCmdVal(`${dedupedResults[0].type}-${dedupedResults[0].id}`)
|
||||
}, [isCommandsMode, dedupedResults, cmdVal, setCmdVal])
|
||||
|
||||
return {
|
||||
searchResults,
|
||||
dedupedResults,
|
||||
groupedResults,
|
||||
isLoading,
|
||||
isError,
|
||||
error: error as Error | null,
|
||||
}
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
import type { ActionItem } from '../actions/types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useGotoAnythingSearch } from './use-goto-anything-search'
|
||||
|
||||
let mockContextValue = { isWorkflowPage: false, isRagPipelinePage: false }
|
||||
let mockMatchActionResult: Partial<ActionItem> | undefined
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounce: <T>(value: T) => value,
|
||||
}))
|
||||
|
||||
vi.mock('../context', () => ({
|
||||
useGotoAnythingContext: () => mockContextValue,
|
||||
}))
|
||||
|
||||
vi.mock('../actions', () => ({
|
||||
createActions: (isWorkflowPage: boolean, isRagPipelinePage: boolean) => {
|
||||
const base = {
|
||||
slash: { key: '/', shortcut: '/' },
|
||||
app: { key: '@app', shortcut: '@app' },
|
||||
knowledge: { key: '@knowledge', shortcut: '@kb' },
|
||||
}
|
||||
if (isWorkflowPage) {
|
||||
return { ...base, node: { key: '@node', shortcut: '@node' } }
|
||||
}
|
||||
if (isRagPipelinePage) {
|
||||
return { ...base, ragNode: { key: '@node', shortcut: '@node' } }
|
||||
}
|
||||
return base
|
||||
},
|
||||
matchAction: () => mockMatchActionResult,
|
||||
}))
|
||||
|
||||
describe('useGotoAnythingSearch', () => {
|
||||
beforeEach(() => {
|
||||
mockContextValue = { isWorkflowPage: false, isRagPipelinePage: false }
|
||||
mockMatchActionResult = undefined
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with empty search query', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
expect(result.current.searchQuery).toBe('')
|
||||
})
|
||||
|
||||
it('should initialize cmdVal with "_"', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
expect(result.current.cmdVal).toBe('_')
|
||||
})
|
||||
|
||||
it('should initialize searchMode as "general"', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
expect(result.current.searchMode).toBe('general')
|
||||
})
|
||||
|
||||
it('should initialize isCommandsMode as false', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
expect(result.current.isCommandsMode).toBe(false)
|
||||
})
|
||||
|
||||
it('should provide setSearchQuery function', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
expect(typeof result.current.setSearchQuery).toBe('function')
|
||||
})
|
||||
|
||||
it('should provide setCmdVal function', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
expect(typeof result.current.setCmdVal).toBe('function')
|
||||
})
|
||||
|
||||
it('should provide clearSelection function', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
expect(typeof result.current.clearSelection).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Actions', () => {
|
||||
it('should provide Actions based on context', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
expect(result.current.Actions).toBeDefined()
|
||||
expect(typeof result.current.Actions).toBe('object')
|
||||
})
|
||||
|
||||
it('should include node action when on workflow page', () => {
|
||||
mockContextValue = { isWorkflowPage: true, isRagPipelinePage: false }
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
expect(result.current.Actions.node).toBeDefined()
|
||||
})
|
||||
|
||||
it('should include ragNode action when on RAG pipeline page', () => {
|
||||
mockContextValue = { isWorkflowPage: false, isRagPipelinePage: true }
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
expect(result.current.Actions.ragNode).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not include node actions when on regular page', () => {
|
||||
mockContextValue = { isWorkflowPage: false, isRagPipelinePage: false }
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
expect(result.current.Actions.node).toBeUndefined()
|
||||
expect(result.current.Actions.ragNode).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCommandsMode', () => {
|
||||
it('should return true when query is exactly "@"', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchQuery('@')
|
||||
})
|
||||
|
||||
expect(result.current.isCommandsMode).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when query is exactly "/"', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchQuery('/')
|
||||
})
|
||||
|
||||
expect(result.current.isCommandsMode).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when query starts with "@" and no action matches', () => {
|
||||
mockMatchActionResult = undefined
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchQuery('@unknown')
|
||||
})
|
||||
|
||||
expect(result.current.isCommandsMode).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when query starts with "/" and no action matches', () => {
|
||||
mockMatchActionResult = undefined
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchQuery('/unknown')
|
||||
})
|
||||
|
||||
expect(result.current.isCommandsMode).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when query starts with "@" and action matches', () => {
|
||||
mockMatchActionResult = { key: '@app', shortcut: '@app' }
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchQuery('@app test')
|
||||
})
|
||||
|
||||
expect(result.current.isCommandsMode).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for regular search query', () => {
|
||||
mockMatchActionResult = undefined
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchQuery('hello world')
|
||||
})
|
||||
|
||||
expect(result.current.isCommandsMode).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchMode', () => {
|
||||
it('should return "general" when query is empty', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
expect(result.current.searchMode).toBe('general')
|
||||
})
|
||||
|
||||
it('should return "scopes" when in commands mode and query starts with "@"', () => {
|
||||
mockMatchActionResult = undefined
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchQuery('@')
|
||||
})
|
||||
|
||||
expect(result.current.searchMode).toBe('scopes')
|
||||
})
|
||||
|
||||
it('should return "commands" when in commands mode and query starts with "/"', () => {
|
||||
mockMatchActionResult = undefined
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchQuery('/')
|
||||
})
|
||||
|
||||
expect(result.current.searchMode).toBe('commands')
|
||||
})
|
||||
|
||||
it('should return "general" when no action matches', () => {
|
||||
mockMatchActionResult = undefined
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchQuery('hello')
|
||||
})
|
||||
|
||||
expect(result.current.searchMode).toBe('general')
|
||||
})
|
||||
|
||||
it('should return action key when action matches', () => {
|
||||
mockMatchActionResult = { key: '@app', shortcut: '@app' }
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchQuery('@app test')
|
||||
})
|
||||
|
||||
expect(result.current.searchMode).toBe('@app')
|
||||
})
|
||||
|
||||
it('should return "@command" when action key is "/"', () => {
|
||||
mockMatchActionResult = { key: '/', shortcut: '/' }
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchQuery('/theme dark')
|
||||
})
|
||||
|
||||
expect(result.current.searchMode).toBe('@command')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearSelection', () => {
|
||||
it('should reset cmdVal to "_"', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
|
||||
// First change cmdVal
|
||||
act(() => {
|
||||
result.current.setCmdVal('app-1')
|
||||
})
|
||||
expect(result.current.cmdVal).toBe('app-1')
|
||||
|
||||
// Then clear
|
||||
act(() => {
|
||||
result.current.clearSelection()
|
||||
})
|
||||
|
||||
expect(result.current.cmdVal).toBe('_')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setSearchQuery', () => {
|
||||
it('should update search query', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchQuery('test query')
|
||||
})
|
||||
|
||||
expect(result.current.searchQuery).toBe('test query')
|
||||
})
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchQuery('test')
|
||||
})
|
||||
expect(result.current.searchQuery).toBe('test')
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchQuery('')
|
||||
})
|
||||
expect(result.current.searchQuery).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setCmdVal', () => {
|
||||
it('should update cmdVal', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setCmdVal('plugin-2')
|
||||
})
|
||||
|
||||
expect(result.current.cmdVal).toBe('plugin-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchQueryDebouncedValue', () => {
|
||||
it('should return trimmed debounced value', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchQuery(' test ')
|
||||
})
|
||||
|
||||
// Since we mock useDebounce to return value directly
|
||||
expect(result.current.searchQueryDebouncedValue).toBe('test')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,77 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ActionItem } from '../actions/types'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { createActions, matchAction } from '../actions'
|
||||
import { useGotoAnythingContext } from '../context'
|
||||
|
||||
export type UseGotoAnythingSearchReturn = {
|
||||
searchQuery: string
|
||||
setSearchQuery: (query: string) => void
|
||||
searchQueryDebouncedValue: string
|
||||
searchMode: string
|
||||
isCommandsMode: boolean
|
||||
cmdVal: string
|
||||
setCmdVal: (val: string) => void
|
||||
clearSelection: () => void
|
||||
Actions: Record<string, ActionItem>
|
||||
}
|
||||
|
||||
export const useGotoAnythingSearch = (): UseGotoAnythingSearchReturn => {
|
||||
const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext()
|
||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||
const [cmdVal, setCmdVal] = useState<string>('_')
|
||||
|
||||
// Filter actions based on context
|
||||
const Actions = useMemo(() => {
|
||||
return createActions(isWorkflowPage, isRagPipelinePage)
|
||||
}, [isWorkflowPage, isRagPipelinePage])
|
||||
|
||||
const searchQueryDebouncedValue = useDebounce(searchQuery.trim(), {
|
||||
wait: 300,
|
||||
})
|
||||
|
||||
const isCommandsMode = useMemo(() => {
|
||||
const trimmed = searchQuery.trim()
|
||||
return trimmed === '@' || trimmed === '/'
|
||||
|| (trimmed.startsWith('@') && !matchAction(trimmed, Actions))
|
||||
|| (trimmed.startsWith('/') && !matchAction(trimmed, Actions))
|
||||
}, [searchQuery, Actions])
|
||||
|
||||
const searchMode = useMemo(() => {
|
||||
if (isCommandsMode) {
|
||||
// Distinguish between @ (scopes) and / (commands) mode
|
||||
if (searchQuery.trim().startsWith('@'))
|
||||
return 'scopes'
|
||||
else if (searchQuery.trim().startsWith('/'))
|
||||
return 'commands'
|
||||
return 'commands' // default fallback
|
||||
}
|
||||
|
||||
const query = searchQueryDebouncedValue.toLowerCase()
|
||||
const action = matchAction(query, Actions)
|
||||
|
||||
if (!action)
|
||||
return 'general'
|
||||
|
||||
return action.key === '/' ? '@command' : action.key
|
||||
}, [searchQueryDebouncedValue, Actions, isCommandsMode, searchQuery])
|
||||
|
||||
// Prevent automatic selection of the first option when cmdVal is not set
|
||||
const clearSelection = useCallback(() => {
|
||||
setCmdVal('_')
|
||||
}, [])
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchQueryDebouncedValue,
|
||||
searchMode,
|
||||
isCommandsMode,
|
||||
cmdVal,
|
||||
setCmdVal,
|
||||
clearSelection,
|
||||
Actions,
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ActionItem, SearchResult } from './actions/types'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import GotoAnything from './index'
|
||||
|
||||
// Test helper type that matches SearchResult but allows ReactNode for icon and flexible data
|
||||
type TestSearchResult = Omit<SearchResult, 'icon' | 'data'> & {
|
||||
icon?: ReactNode
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// Mock react-i18next to return namespace.key format
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
const ns = options?.ns || 'common'
|
||||
return `${ns}.${key}`
|
||||
},
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}))
|
||||
|
||||
const routerPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
@@ -30,15 +12,10 @@ vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/',
|
||||
}))
|
||||
|
||||
type KeyPressEvent = {
|
||||
preventDefault: () => void
|
||||
target?: EventTarget
|
||||
}
|
||||
|
||||
const keyPressHandlers: Record<string, (event: KeyPressEvent) => void> = {}
|
||||
const keyPressHandlers: Record<string, (event: any) => void> = {}
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounce: <T,>(value: T) => value,
|
||||
useKeyPress: (keys: string | string[], handler: (event: KeyPressEvent) => void) => {
|
||||
useDebounce: (value: any) => value,
|
||||
useKeyPress: (keys: string | string[], handler: (event: any) => void) => {
|
||||
const keyList = Array.isArray(keys) ? keys : [keys]
|
||||
keyList.forEach((key) => {
|
||||
keyPressHandlers[key] = handler
|
||||
@@ -55,7 +32,7 @@ const triggerKeyPress = (combo: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
let mockQueryResult = { data: [] as TestSearchResult[], isLoading: false, isError: false, error: null as Error | null }
|
||||
let mockQueryResult = { data: [] as SearchResult[], isLoading: false, isError: false, error: null as Error | null }
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: () => mockQueryResult,
|
||||
}))
|
||||
@@ -99,16 +76,9 @@ vi.mock('./actions/commands', () => ({
|
||||
SlashCommandProvider: () => null,
|
||||
}))
|
||||
|
||||
type MockSlashCommand = {
|
||||
mode: string
|
||||
execute?: () => void
|
||||
isAvailable?: () => boolean
|
||||
} | null
|
||||
|
||||
let mockFindCommand: MockSlashCommand = null
|
||||
vi.mock('./actions/commands/registry', () => ({
|
||||
slashCommandRegistry: {
|
||||
findCommand: () => mockFindCommand,
|
||||
findCommand: () => null,
|
||||
getAvailableCommands: () => [],
|
||||
getAllCommands: () => [],
|
||||
},
|
||||
@@ -116,7 +86,6 @@ vi.mock('./actions/commands/registry', () => ({
|
||||
|
||||
vi.mock('@/app/components/workflow/utils/common', () => ({
|
||||
getKeyboardKeyCodeBySystem: () => 'ctrl',
|
||||
getKeyboardKeyNameBySystem: (key: string) => key,
|
||||
isEventTargetInputArea: () => false,
|
||||
isMac: () => false,
|
||||
}))
|
||||
@@ -126,11 +95,10 @@ vi.mock('@/app/components/workflow/utils/node-navigation', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../plugins/install-plugin/install-from-marketplace', () => ({
|
||||
default: (props: { manifest?: { name?: string }, onClose: () => void, onSuccess: () => void }) => (
|
||||
default: (props: { manifest?: { name?: string }, onClose: () => void }) => (
|
||||
<div data-testid="install-modal">
|
||||
<span>{props.manifest?.name}</span>
|
||||
<button onClick={props.onClose} data-testid="close-install">close</button>
|
||||
<button onClick={props.onSuccess} data-testid="success-install">success</button>
|
||||
<button onClick={props.onClose}>close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@@ -142,504 +110,65 @@ describe('GotoAnything', () => {
|
||||
mockQueryResult = { data: [], isLoading: false, isError: false, error: null }
|
||||
matchActionMock.mockReset()
|
||||
searchAnythingMock.mockClear()
|
||||
mockFindCommand = null
|
||||
})
|
||||
|
||||
describe('modal behavior', () => {
|
||||
it('should open modal via Ctrl+K shortcut', async () => {
|
||||
render(<GotoAnything />)
|
||||
it('should open modal via shortcut and navigate to selected result', async () => {
|
||||
mockQueryResult = {
|
||||
data: [{
|
||||
id: 'app-1',
|
||||
type: 'app',
|
||||
title: 'Sample App',
|
||||
description: 'desc',
|
||||
path: '/apps/1',
|
||||
icon: <div data-testid="icon">🧩</div>,
|
||||
data: {},
|
||||
} as any],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
triggerKeyPress('ctrl.k')
|
||||
render(<GotoAnything />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
triggerKeyPress('ctrl.k')
|
||||
|
||||
it('should close modal via ESC key', async () => {
|
||||
render(<GotoAnything />)
|
||||
const input = await screen.findByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await userEvent.type(input, 'app')
|
||||
|
||||
triggerKeyPress('ctrl.k')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
const result = await screen.findByText('Sample App')
|
||||
await userEvent.click(result)
|
||||
|
||||
triggerKeyPress('esc')
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should toggle modal when pressing Ctrl+K twice', async () => {
|
||||
render(<GotoAnything />)
|
||||
|
||||
triggerKeyPress('ctrl.k')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
triggerKeyPress('ctrl.k')
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onHide when modal closes', async () => {
|
||||
const onHide = vi.fn()
|
||||
render(<GotoAnything onHide={onHide} />)
|
||||
|
||||
triggerKeyPress('ctrl.k')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
triggerKeyPress('esc')
|
||||
await waitFor(() => {
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should reset search query when modal opens', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<GotoAnything />)
|
||||
|
||||
// Open modal first time
|
||||
triggerKeyPress('ctrl.k')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Type something
|
||||
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await user.type(input, 'test')
|
||||
|
||||
// Close modal
|
||||
triggerKeyPress('esc')
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Open modal again - should be empty
|
||||
triggerKeyPress('ctrl.k')
|
||||
await waitFor(() => {
|
||||
const newInput = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
expect(newInput).toHaveValue('')
|
||||
})
|
||||
})
|
||||
expect(routerPush).toHaveBeenCalledWith('/apps/1')
|
||||
})
|
||||
|
||||
describe('search functionality', () => {
|
||||
it('should navigate to selected result', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockQueryResult = {
|
||||
data: [{
|
||||
id: 'app-1',
|
||||
type: 'app',
|
||||
title: 'Sample App',
|
||||
description: 'desc',
|
||||
path: '/apps/1',
|
||||
icon: <div data-testid="icon">🧩</div>,
|
||||
data: {},
|
||||
}],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
render(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await user.type(input, 'app')
|
||||
|
||||
const result = await screen.findByText('Sample App')
|
||||
await user.click(result)
|
||||
|
||||
expect(routerPush).toHaveBeenCalledWith('/apps/1')
|
||||
})
|
||||
|
||||
it('should clear selection when typing without prefix', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await user.type(input, 'test query')
|
||||
|
||||
// Should not throw and input should have value
|
||||
expect(input).toHaveValue('test query')
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty states', () => {
|
||||
it('should show loading state', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockQueryResult = {
|
||||
data: [],
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
render(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await user.type(input, 'search')
|
||||
|
||||
// Loading state shows in both EmptyState (spinner) and Footer
|
||||
const searchingTexts = screen.getAllByText('app.gotoAnything.searching')
|
||||
expect(searchingTexts.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should show error state', async () => {
|
||||
const user = userEvent.setup()
|
||||
const testError = new Error('Search failed')
|
||||
mockQueryResult = {
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: testError,
|
||||
}
|
||||
|
||||
render(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await user.type(input, 'search')
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.searchFailed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show default state when no query', async () => {
|
||||
render(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.searchTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show no results state when search returns empty', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockQueryResult = {
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
render(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await user.type(input, 'nonexistent')
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.noResults')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin installation', () => {
|
||||
it('should open plugin installer when selecting plugin result', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockQueryResult = {
|
||||
data: [{
|
||||
id: 'plugin-1',
|
||||
type: 'plugin',
|
||||
title: 'Plugin Item',
|
||||
description: 'desc',
|
||||
path: '',
|
||||
icon: <div />,
|
||||
data: {
|
||||
name: 'Plugin Item',
|
||||
latest_package_identifier: 'pkg',
|
||||
},
|
||||
}],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
render(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await user.type(input, 'plugin')
|
||||
|
||||
const pluginItem = await screen.findByText('Plugin Item')
|
||||
await user.click(pluginItem)
|
||||
|
||||
expect(await screen.findByTestId('install-modal')).toHaveTextContent('Plugin Item')
|
||||
})
|
||||
|
||||
it('should close plugin installer via close button', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockQueryResult = {
|
||||
data: [{
|
||||
id: 'plugin-1',
|
||||
type: 'plugin',
|
||||
title: 'Plugin Item',
|
||||
description: 'desc',
|
||||
path: '',
|
||||
icon: <div />,
|
||||
data: {
|
||||
name: 'Plugin Item',
|
||||
latest_package_identifier: 'pkg',
|
||||
},
|
||||
}],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
render(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await user.type(input, 'plugin')
|
||||
|
||||
const pluginItem = await screen.findByText('Plugin Item')
|
||||
await user.click(pluginItem)
|
||||
|
||||
const closeBtn = await screen.findByTestId('close-install')
|
||||
await user.click(closeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close plugin installer on success', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockQueryResult = {
|
||||
data: [{
|
||||
id: 'plugin-1',
|
||||
type: 'plugin',
|
||||
title: 'Plugin Item',
|
||||
description: 'desc',
|
||||
path: '',
|
||||
icon: <div />,
|
||||
data: {
|
||||
name: 'Plugin Item',
|
||||
latest_package_identifier: 'pkg',
|
||||
},
|
||||
}],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
render(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await user.type(input, 'plugin')
|
||||
|
||||
const pluginItem = await screen.findByText('Plugin Item')
|
||||
await user.click(pluginItem)
|
||||
|
||||
const successBtn = await screen.findByTestId('success-install')
|
||||
await user.click(successBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('slash command handling', () => {
|
||||
it('should execute direct slash command on Enter', async () => {
|
||||
const user = userEvent.setup()
|
||||
const executeMock = vi.fn()
|
||||
mockFindCommand = {
|
||||
mode: 'direct',
|
||||
execute: executeMock,
|
||||
isAvailable: () => true,
|
||||
}
|
||||
|
||||
render(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await user.type(input, '/theme')
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(executeMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT execute unavailable slash command', async () => {
|
||||
const user = userEvent.setup()
|
||||
const executeMock = vi.fn()
|
||||
mockFindCommand = {
|
||||
mode: 'direct',
|
||||
execute: executeMock,
|
||||
isAvailable: () => false,
|
||||
}
|
||||
|
||||
render(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await user.type(input, '/theme')
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(executeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT execute non-direct mode slash command on Enter', async () => {
|
||||
const user = userEvent.setup()
|
||||
const executeMock = vi.fn()
|
||||
mockFindCommand = {
|
||||
mode: 'submenu',
|
||||
execute: executeMock,
|
||||
}
|
||||
|
||||
render(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await user.type(input, '/language')
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(executeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close modal after executing direct slash command', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockFindCommand = {
|
||||
mode: 'direct',
|
||||
execute: vi.fn(),
|
||||
isAvailable: () => true,
|
||||
}
|
||||
|
||||
render(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await user.type(input, '/theme')
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('result navigation', () => {
|
||||
it('should handle knowledge result navigation', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockQueryResult = {
|
||||
data: [{
|
||||
id: 'kb-1',
|
||||
type: 'knowledge',
|
||||
title: 'Knowledge Base',
|
||||
description: 'desc',
|
||||
path: '/datasets/kb-1',
|
||||
icon: <div />,
|
||||
data: {},
|
||||
}],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
render(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await user.type(input, 'knowledge')
|
||||
|
||||
const result = await screen.findByText('Knowledge Base')
|
||||
await user.click(result)
|
||||
|
||||
expect(routerPush).toHaveBeenCalledWith('/datasets/kb-1')
|
||||
})
|
||||
|
||||
it('should NOT navigate when result has no path', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockQueryResult = {
|
||||
data: [{
|
||||
id: 'item-1',
|
||||
type: 'app',
|
||||
title: 'No Path Item',
|
||||
description: 'desc',
|
||||
path: '',
|
||||
icon: <div />,
|
||||
data: {},
|
||||
}],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
render(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await user.type(input, 'no path')
|
||||
|
||||
const result = await screen.findByText('No Path Item')
|
||||
await user.click(result)
|
||||
|
||||
expect(routerPush).not.toHaveBeenCalled()
|
||||
})
|
||||
it('should open plugin installer when selecting plugin result', async () => {
|
||||
mockQueryResult = {
|
||||
data: [{
|
||||
id: 'plugin-1',
|
||||
type: 'plugin',
|
||||
title: 'Plugin Item',
|
||||
description: 'desc',
|
||||
path: '',
|
||||
icon: <div />,
|
||||
data: {
|
||||
name: 'Plugin Item',
|
||||
latest_package_identifier: 'pkg',
|
||||
},
|
||||
} as any],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
render(<GotoAnything />)
|
||||
|
||||
triggerKeyPress('ctrl.k')
|
||||
const input = await screen.findByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await userEvent.type(input, 'plugin')
|
||||
|
||||
const pluginItem = await screen.findByText('Plugin Item')
|
||||
await userEvent.click(pluginItem)
|
||||
|
||||
expect(await screen.findByTestId('install-modal')).toHaveTextContent('Plugin Item')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,149 +1,300 @@
|
||||
'use client'
|
||||
|
||||
import type { FC, KeyboardEvent } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import type { Plugin } from '../plugins/types'
|
||||
import type { SearchResult } from './actions'
|
||||
import { RiSearchLine } from '@remixicon/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useDebounce, useKeyPress } from 'ahooks'
|
||||
import { Command } from 'cmdk'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
import { getKeyboardKeyCodeBySystem, isEventTargetInputArea } from '@/app/components/workflow/utils/common'
|
||||
import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import InstallFromMarketplace from '../plugins/install-plugin/install-from-marketplace'
|
||||
import { createActions, matchAction, searchAnything } from './actions'
|
||||
import { SlashCommandProvider } from './actions/commands'
|
||||
import { slashCommandRegistry } from './actions/commands/registry'
|
||||
import CommandSelector from './command-selector'
|
||||
import { EmptyState, Footer, ResultList, SearchInput } from './components'
|
||||
import { GotoAnythingProvider, useGotoAnythingContext } from './context'
|
||||
import {
|
||||
useGotoAnythingModal,
|
||||
useGotoAnythingNavigation,
|
||||
useGotoAnythingResults,
|
||||
useGotoAnythingSearch,
|
||||
} from './hooks'
|
||||
|
||||
type Props = {
|
||||
onHide?: () => void
|
||||
}
|
||||
|
||||
const GotoAnything: FC<Props> = ({
|
||||
onHide,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const defaultLocale = useGetLanguage()
|
||||
const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext()
|
||||
const prevShowRef = useRef(false)
|
||||
const { t } = useTranslation()
|
||||
const [show, setShow] = useState<boolean>(false)
|
||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||
const [cmdVal, setCmdVal] = useState<string>('_')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Search state management (called first so setSearchQuery is available)
|
||||
const {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchQueryDebouncedValue,
|
||||
searchMode,
|
||||
isCommandsMode,
|
||||
cmdVal,
|
||||
setCmdVal,
|
||||
clearSelection,
|
||||
Actions,
|
||||
} = useGotoAnythingSearch()
|
||||
// Filter actions based on context
|
||||
const Actions = useMemo(() => {
|
||||
// Create actions based on current page context
|
||||
return createActions(isWorkflowPage, isRagPipelinePage)
|
||||
}, [isWorkflowPage, isRagPipelinePage])
|
||||
|
||||
// Modal state management
|
||||
const {
|
||||
show,
|
||||
setShow,
|
||||
inputRef,
|
||||
handleClose: modalClose,
|
||||
} = useGotoAnythingModal()
|
||||
const [activePlugin, setActivePlugin] = useState<Plugin>()
|
||||
|
||||
// Reset state when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (show && !prevShowRef.current) {
|
||||
// Modal just opened - reset search
|
||||
setSearchQuery('')
|
||||
}
|
||||
else if (!show && prevShowRef.current) {
|
||||
// Modal just closed
|
||||
setSearchQuery('')
|
||||
clearSelection()
|
||||
onHide?.()
|
||||
}
|
||||
prevShowRef.current = show
|
||||
}, [show, setSearchQuery, clearSelection, onHide])
|
||||
// Handle keyboard shortcuts
|
||||
const handleToggleModal = useCallback((e: KeyboardEvent) => {
|
||||
// Allow closing when modal is open, even if focus is in the search input
|
||||
if (!show && isEventTargetInputArea(e.target as HTMLElement))
|
||||
return
|
||||
e.preventDefault()
|
||||
setShow((prev) => {
|
||||
if (!prev) {
|
||||
// Opening modal - reset search state
|
||||
setSearchQuery('')
|
||||
}
|
||||
return !prev
|
||||
})
|
||||
}, [show])
|
||||
|
||||
// Results fetching and processing
|
||||
const {
|
||||
dedupedResults,
|
||||
groupedResults,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useGotoAnythingResults({
|
||||
searchQueryDebouncedValue,
|
||||
searchMode,
|
||||
isCommandsMode,
|
||||
Actions,
|
||||
isWorkflowPage,
|
||||
isRagPipelinePage,
|
||||
cmdVal,
|
||||
setCmdVal,
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.k`, handleToggleModal, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
// Navigation handlers
|
||||
const {
|
||||
handleCommandSelect,
|
||||
handleNavigate,
|
||||
activePlugin,
|
||||
setActivePlugin,
|
||||
} = useGotoAnythingNavigation({
|
||||
Actions,
|
||||
setSearchQuery,
|
||||
clearSelection,
|
||||
inputRef,
|
||||
onClose: () => setShow(false),
|
||||
useKeyPress(['esc'], (e) => {
|
||||
if (show) {
|
||||
e.preventDefault()
|
||||
setShow(false)
|
||||
setSearchQuery('')
|
||||
}
|
||||
})
|
||||
|
||||
// Handle search input change
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchQuery(value)
|
||||
if (!value.startsWith('@') && !value.startsWith('/'))
|
||||
clearSelection()
|
||||
}, [setSearchQuery, clearSelection])
|
||||
const searchQueryDebouncedValue = useDebounce(searchQuery.trim(), {
|
||||
wait: 300,
|
||||
})
|
||||
|
||||
// Handle search input keydown for slash commands
|
||||
const handleSearchKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
const query = searchQuery.trim()
|
||||
// Check if it's a complete slash command
|
||||
if (query.startsWith('/')) {
|
||||
const commandName = query.substring(1).split(' ')[0]
|
||||
const handler = slashCommandRegistry.findCommand(commandName)
|
||||
const isCommandsMode = searchQuery.trim() === '@' || searchQuery.trim() === '/'
|
||||
|| (searchQuery.trim().startsWith('@') && !matchAction(searchQuery.trim(), Actions))
|
||||
|| (searchQuery.trim().startsWith('/') && !matchAction(searchQuery.trim(), Actions))
|
||||
|
||||
// If it's a direct mode command, execute immediately
|
||||
const isAvailable = handler?.isAvailable?.() ?? true
|
||||
if (handler?.mode === 'direct' && handler.execute && isAvailable) {
|
||||
e.preventDefault()
|
||||
handler.execute()
|
||||
setShow(false)
|
||||
setSearchQuery('')
|
||||
}
|
||||
const searchMode = useMemo(() => {
|
||||
if (isCommandsMode) {
|
||||
// Distinguish between @ (scopes) and / (commands) mode
|
||||
if (searchQuery.trim().startsWith('@'))
|
||||
return 'scopes'
|
||||
else if (searchQuery.trim().startsWith('/'))
|
||||
return 'commands'
|
||||
return 'commands' // default fallback
|
||||
}
|
||||
|
||||
const query = searchQueryDebouncedValue.toLowerCase()
|
||||
const action = matchAction(query, Actions)
|
||||
|
||||
if (!action)
|
||||
return 'general'
|
||||
|
||||
return action.key === '/' ? '@command' : action.key
|
||||
}, [searchQueryDebouncedValue, Actions, isCommandsMode, searchQuery])
|
||||
|
||||
const { data: searchResults = [], isLoading, isError, error } = useQuery(
|
||||
{
|
||||
queryKey: [
|
||||
'goto-anything',
|
||||
'search-result',
|
||||
searchQueryDebouncedValue,
|
||||
searchMode,
|
||||
isWorkflowPage,
|
||||
isRagPipelinePage,
|
||||
defaultLocale,
|
||||
Actions,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const query = searchQueryDebouncedValue.toLowerCase()
|
||||
const action = matchAction(query, Actions)
|
||||
return await searchAnything(defaultLocale, query, action, Actions)
|
||||
},
|
||||
enabled: !!searchQueryDebouncedValue && !isCommandsMode,
|
||||
staleTime: 30000,
|
||||
gcTime: 300000,
|
||||
},
|
||||
)
|
||||
|
||||
// Prevent automatic selection of the first option when cmdVal is not set
|
||||
const clearSelection = () => {
|
||||
setCmdVal('_')
|
||||
}
|
||||
|
||||
const handleCommandSelect = useCallback((commandKey: string) => {
|
||||
// Check if it's a slash command
|
||||
if (commandKey.startsWith('/')) {
|
||||
const commandName = commandKey.substring(1)
|
||||
const handler = slashCommandRegistry.findCommand(commandName)
|
||||
|
||||
// If it's a direct mode command, execute immediately
|
||||
if (handler?.mode === 'direct' && handler.execute) {
|
||||
handler.execute()
|
||||
setShow(false)
|
||||
setSearchQuery('')
|
||||
return
|
||||
}
|
||||
}
|
||||
}, [searchQuery, setShow, setSearchQuery])
|
||||
|
||||
// Determine which empty state to show
|
||||
const emptyStateVariant = useMemo(() => {
|
||||
if (isLoading)
|
||||
return 'loading'
|
||||
if (isError)
|
||||
return 'error'
|
||||
if (!searchQuery.trim())
|
||||
return 'default'
|
||||
if (dedupedResults.length === 0 && !isCommandsMode)
|
||||
return 'no-results'
|
||||
return null
|
||||
}, [isLoading, isError, searchQuery, dedupedResults.length, isCommandsMode])
|
||||
// Otherwise, proceed with the normal flow (submenu mode)
|
||||
setSearchQuery(`${commandKey} `)
|
||||
clearSelection()
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus()
|
||||
}, 0)
|
||||
}, [])
|
||||
|
||||
// Handle navigation to selected result
|
||||
const handleNavigate = useCallback((result: SearchResult) => {
|
||||
setShow(false)
|
||||
setSearchQuery('')
|
||||
|
||||
switch (result.type) {
|
||||
case 'command': {
|
||||
// Execute slash commands
|
||||
const action = Actions.slash
|
||||
action?.action?.(result)
|
||||
break
|
||||
}
|
||||
case 'plugin':
|
||||
setActivePlugin(result.data)
|
||||
break
|
||||
case 'workflow-node':
|
||||
// Handle workflow node selection and navigation
|
||||
if (result.metadata?.nodeId)
|
||||
selectWorkflowNode(result.metadata.nodeId, true)
|
||||
|
||||
break
|
||||
default:
|
||||
if (result.path)
|
||||
router.push(result.path)
|
||||
}
|
||||
}, [router])
|
||||
|
||||
const dedupedResults = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
return searchResults.filter((result) => {
|
||||
const key = `${result.type}-${result.id}`
|
||||
if (seen.has(key))
|
||||
return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}, [searchResults])
|
||||
|
||||
// Group results by type
|
||||
const groupedResults = useMemo(() => dedupedResults.reduce((acc, result) => {
|
||||
if (!acc[result.type])
|
||||
acc[result.type] = []
|
||||
|
||||
acc[result.type].push(result)
|
||||
return acc
|
||||
}, {} as { [key: string]: SearchResult[] }), [dedupedResults])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCommandsMode)
|
||||
return
|
||||
|
||||
if (!dedupedResults.length)
|
||||
return
|
||||
|
||||
const currentValueExists = dedupedResults.some(result => `${result.type}-${result.id}` === cmdVal)
|
||||
|
||||
if (!currentValueExists)
|
||||
setCmdVal(`${dedupedResults[0].type}-${dedupedResults[0].id}`)
|
||||
}, [isCommandsMode, dedupedResults, cmdVal])
|
||||
|
||||
const emptyResult = useMemo(() => {
|
||||
if (dedupedResults.length || !searchQuery.trim() || isLoading || isCommandsMode)
|
||||
return null
|
||||
|
||||
const isCommandSearch = searchMode !== 'general'
|
||||
const commandType = isCommandSearch ? searchMode.replace('@', '') : ''
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-red-500">{t('gotoAnything.searchTemporarilyUnavailable', { ns: 'app' })}</div>
|
||||
<div className="mt-1 text-xs text-text-quaternary">
|
||||
{t('gotoAnything.servicesUnavailableMessage', { ns: 'app' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{isCommandSearch
|
||||
? (() => {
|
||||
const keyMap = {
|
||||
app: 'gotoAnything.emptyState.noAppsFound',
|
||||
plugin: 'gotoAnything.emptyState.noPluginsFound',
|
||||
knowledge: 'gotoAnything.emptyState.noKnowledgeBasesFound',
|
||||
node: 'gotoAnything.emptyState.noWorkflowNodesFound',
|
||||
} as const
|
||||
return t(keyMap[commandType as keyof typeof keyMap] || 'gotoAnything.noResults', { ns: 'app' })
|
||||
})()
|
||||
: t('gotoAnything.noResults', { ns: 'app' })}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-quaternary">
|
||||
{isCommandSearch
|
||||
? t('gotoAnything.emptyState.tryDifferentTerm', { ns: 'app' })
|
||||
: t('gotoAnything.emptyState.trySpecificSearch', { ns: 'app', shortcuts: Object.values(Actions).map(action => action.shortcut).join(', ') })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [dedupedResults, searchQuery, Actions, searchMode, isLoading, isError, isCommandsMode])
|
||||
|
||||
const defaultUI = useMemo(() => {
|
||||
if (searchQuery.trim())
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('gotoAnything.searchTitle', { ns: 'app' })}</div>
|
||||
<div className="mt-3 space-y-1 text-xs text-text-quaternary">
|
||||
<div>{t('gotoAnything.searchHint', { ns: 'app' })}</div>
|
||||
<div>{t('gotoAnything.commandHint', { ns: 'app' })}</div>
|
||||
<div>{t('gotoAnything.slashHint', { ns: 'app' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [searchQuery, Actions])
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus()
|
||||
})
|
||||
}
|
||||
}, [show])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlashCommandProvider />
|
||||
<Modal
|
||||
isShow={show}
|
||||
onClose={modalClose}
|
||||
onClose={() => {
|
||||
setShow(false)
|
||||
setSearchQuery('')
|
||||
clearSelection()
|
||||
onHide?.()
|
||||
}}
|
||||
closable={false}
|
||||
className="!w-[480px] !p-0"
|
||||
highPriority={true}
|
||||
@@ -156,24 +307,78 @@ const GotoAnything: FC<Props> = ({
|
||||
disablePointerSelection
|
||||
loop
|
||||
>
|
||||
<SearchInput
|
||||
inputRef={inputRef}
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
searchMode={searchMode}
|
||||
placeholder={t('gotoAnything.searchPlaceholder', { ns: 'app' })}
|
||||
/>
|
||||
<div className="flex items-center gap-3 border-b border-divider-subtle bg-components-panel-bg-blur px-4 py-3">
|
||||
<RiSearchLine className="h-4 w-4 text-text-quaternary" />
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={searchQuery}
|
||||
placeholder={t('gotoAnything.searchPlaceholder', { ns: 'app' })}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
if (!e.target.value.startsWith('@') && !e.target.value.startsWith('/'))
|
||||
clearSelection()
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const query = searchQuery.trim()
|
||||
// Check if it's a complete slash command
|
||||
if (query.startsWith('/')) {
|
||||
const commandName = query.substring(1).split(' ')[0]
|
||||
const handler = slashCommandRegistry.findCommand(commandName)
|
||||
|
||||
// If it's a direct mode command, execute immediately
|
||||
const isAvailable = handler?.isAvailable?.() ?? true
|
||||
if (handler?.mode === 'direct' && handler.execute && isAvailable) {
|
||||
e.preventDefault()
|
||||
handler.execute()
|
||||
setShow(false)
|
||||
setSearchQuery('')
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="flex-1 !border-0 !bg-transparent !shadow-none"
|
||||
wrapperClassName="flex-1 !border-0 !bg-transparent"
|
||||
autoFocus
|
||||
/>
|
||||
{searchMode !== 'general' && (
|
||||
<div className="flex items-center gap-1 rounded bg-gray-100 px-2 py-[2px] text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
<span>
|
||||
{(() => {
|
||||
if (searchMode === 'scopes')
|
||||
return 'SCOPES'
|
||||
else if (searchMode === 'commands')
|
||||
return 'COMMANDS'
|
||||
else
|
||||
return searchMode.replace('@', '').toUpperCase()
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ShortcutsName keys={['ctrl', 'K']} textColor="secondary" />
|
||||
</div>
|
||||
|
||||
<Command.List className="h-[240px] overflow-y-auto">
|
||||
{emptyStateVariant === 'loading' && (
|
||||
<EmptyState variant="loading" />
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600"></div>
|
||||
<span className="text-sm">{t('gotoAnything.searching', { ns: 'app' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{emptyStateVariant === 'error' && (
|
||||
<EmptyState variant="error" error={error} />
|
||||
{isError && (
|
||||
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-red-500">{t('gotoAnything.searchFailed', { ns: 'app' })}</div>
|
||||
<div className="mt-1 text-xs text-text-quaternary">
|
||||
{error.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && (
|
||||
<>
|
||||
{isCommandsMode
|
||||
@@ -188,46 +393,118 @@ const GotoAnything: FC<Props> = ({
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<ResultList
|
||||
groupedResults={groupedResults}
|
||||
onSelect={handleNavigate}
|
||||
/>
|
||||
Object.entries(groupedResults).map(([type, results], groupIndex) => (
|
||||
<Command.Group
|
||||
key={groupIndex}
|
||||
heading={(() => {
|
||||
const typeMap = {
|
||||
'app': 'gotoAnything.groups.apps',
|
||||
'plugin': 'gotoAnything.groups.plugins',
|
||||
'knowledge': 'gotoAnything.groups.knowledgeBases',
|
||||
'workflow-node': 'gotoAnything.groups.workflowNodes',
|
||||
'command': 'gotoAnything.groups.commands',
|
||||
} as const
|
||||
return t(typeMap[type as keyof typeof typeMap] || `${type}s`, { ns: 'app' })
|
||||
})()}
|
||||
className="p-2 capitalize text-text-secondary"
|
||||
>
|
||||
{results.map(result => (
|
||||
<Command.Item
|
||||
key={`${result.type}-${result.id}`}
|
||||
value={`${result.type}-${result.id}`}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-md p-3 will-change-[background-color] hover:bg-state-base-hover aria-[selected=true]:bg-state-base-hover-alt data-[selected=true]:bg-state-base-hover-alt"
|
||||
onSelect={() => handleNavigate(result)}
|
||||
>
|
||||
{result.icon}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-text-secondary">
|
||||
{result.title}
|
||||
</div>
|
||||
{result.description && (
|
||||
<div className="mt-0.5 truncate text-xs text-text-quaternary">
|
||||
{result.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs capitalize text-text-quaternary">
|
||||
{result.type}
|
||||
</div>
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
))
|
||||
)}
|
||||
|
||||
{!isCommandsMode && emptyStateVariant === 'no-results' && (
|
||||
<EmptyState
|
||||
variant="no-results"
|
||||
searchMode={searchMode}
|
||||
Actions={Actions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isCommandsMode && emptyStateVariant === 'default' && (
|
||||
<EmptyState variant="default" />
|
||||
)}
|
||||
{!isCommandsMode && emptyResult}
|
||||
{!isCommandsMode && defaultUI}
|
||||
</>
|
||||
)}
|
||||
</Command.List>
|
||||
|
||||
<Footer
|
||||
resultCount={dedupedResults.length}
|
||||
searchMode={searchMode}
|
||||
isError={isError}
|
||||
isCommandsMode={isCommandsMode}
|
||||
hasQuery={!!searchQuery.trim()}
|
||||
/>
|
||||
{/* Always show footer to prevent height jumping */}
|
||||
<div className="border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-2 text-xs text-text-tertiary">
|
||||
<div className="flex min-h-[16px] items-center justify-between">
|
||||
{(!!dedupedResults.length || isError)
|
||||
? (
|
||||
<>
|
||||
<span>
|
||||
{isError
|
||||
? (
|
||||
<span className="text-red-500">{t('gotoAnything.someServicesUnavailable', { ns: 'app' })}</span>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{t('gotoAnything.resultCount', { ns: 'app', count: dedupedResults.length })}
|
||||
{searchMode !== 'general' && (
|
||||
<span className="ml-2 opacity-60">
|
||||
{t('gotoAnything.inScope', { ns: 'app', scope: searchMode.replace('@', '') })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className="opacity-60">
|
||||
{searchMode !== 'general'
|
||||
? t('gotoAnything.clearToSearchAll', { ns: 'app' })
|
||||
: t('gotoAnything.useAtForSpecific', { ns: 'app' })}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<span className="opacity-60">
|
||||
{(() => {
|
||||
if (isCommandsMode)
|
||||
return t('gotoAnything.selectToNavigate', { ns: 'app' })
|
||||
|
||||
if (searchQuery.trim())
|
||||
return t('gotoAnything.searching', { ns: 'app' })
|
||||
|
||||
return t('gotoAnything.startTyping', { ns: 'app' })
|
||||
})()}
|
||||
</span>
|
||||
<span className="opacity-60">
|
||||
{searchQuery.trim() || isCommandsMode
|
||||
? t('gotoAnything.tips', { ns: 'app' })
|
||||
: t('gotoAnything.pressEscToClose', { ns: 'app' })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Command>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{activePlugin && (
|
||||
<InstallFromMarketplace
|
||||
manifest={activePlugin}
|
||||
uniqueIdentifier={activePlugin.latest_package_identifier}
|
||||
onClose={() => setActivePlugin(undefined)}
|
||||
onSuccess={() => setActivePlugin(undefined)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
{
|
||||
activePlugin && (
|
||||
<InstallFromMarketplace
|
||||
manifest={activePlugin}
|
||||
uniqueIdentifier={activePlugin.latest_package_identifier}
|
||||
onClose={() => setActivePlugin(undefined)}
|
||||
onSuccess={() => setActivePlugin(undefined)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -551,8 +551,8 @@ describe('WorkflowOnboardingModal', () => {
|
||||
|
||||
// Assert
|
||||
const escKey = screen.getByText('workflow.onboarding.escTip.key')
|
||||
// ShortcutsName renders a <div> with class system-kbd, not a <kbd> element
|
||||
expect(escKey.closest('.system-kbd')).toBeInTheDocument()
|
||||
expect(escKey.closest('kbd')).toBeInTheDocument()
|
||||
expect(escKey.closest('kbd')).toHaveClass('system-kbd')
|
||||
})
|
||||
|
||||
it('should have descriptive text for ESC functionality', () => {
|
||||
|
||||
@@ -1,705 +0,0 @@
|
||||
/**
|
||||
* Test Suite for useNodesSyncDraft Hook
|
||||
*
|
||||
* PURPOSE:
|
||||
* This hook handles syncing workflow draft to the server. The key fix being tested
|
||||
* is the error handling behavior when `draft_workflow_not_sync` error occurs.
|
||||
*
|
||||
* MULTI-TAB PROBLEM SCENARIO:
|
||||
* 1. User opens the same workflow in Tab A and Tab B (both have hash: v1)
|
||||
* 2. Tab A saves successfully, server returns new hash: v2
|
||||
* 3. Tab B tries to save with old hash: v1, server returns 400 error with code
|
||||
* 'draft_workflow_not_sync'
|
||||
* 4. BEFORE FIX: handleRefreshWorkflowDraft() was called without args, which fetched
|
||||
* draft AND overwrote canvas - user lost unsaved changes in Tab B
|
||||
* 5. AFTER FIX: handleRefreshWorkflowDraft(true) is called, which fetches draft but
|
||||
* only updates hash (notUpdateCanvas=true), preserving user's canvas changes
|
||||
*
|
||||
* TESTING STRATEGY:
|
||||
* We don't simulate actual tab switching UI behavior. Instead, we mock the API to
|
||||
* return `draft_workflow_not_sync` error and verify:
|
||||
* - The hook calls handleRefreshWorkflowDraft(true) - not handleRefreshWorkflowDraft()
|
||||
* - This ensures canvas data is preserved while hash is updated for retry
|
||||
*
|
||||
* This is behavior-driven testing - we verify "what the code does when receiving
|
||||
* specific API errors" rather than simulating complete user interaction flows.
|
||||
* True multi-tab integration testing would require E2E frameworks like Playwright.
|
||||
*/
|
||||
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
|
||||
// Mock reactflow store
|
||||
const mockGetNodes = vi.fn()
|
||||
|
||||
type MockEdge = {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
const mockStoreState: {
|
||||
getNodes: ReturnType<typeof vi.fn>
|
||||
edges: MockEdge[]
|
||||
transform: number[]
|
||||
} = {
|
||||
getNodes: mockGetNodes,
|
||||
edges: [],
|
||||
transform: [0, 0, 1],
|
||||
}
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => ({
|
||||
getState: () => mockStoreState,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock features store
|
||||
const mockFeaturesState = {
|
||||
features: {
|
||||
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
|
||||
suggested: {},
|
||||
text2speech: {},
|
||||
speech2text: {},
|
||||
citation: {},
|
||||
moderation: {},
|
||||
file: {},
|
||||
},
|
||||
}
|
||||
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
useFeaturesStore: () => ({
|
||||
getState: () => mockFeaturesState,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock workflow service
|
||||
const mockSyncWorkflowDraft = vi.fn()
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
syncWorkflowDraft: (...args: unknown[]) => mockSyncWorkflowDraft(...args),
|
||||
}))
|
||||
|
||||
// Mock useNodesReadOnly
|
||||
const mockGetNodesReadOnly = vi.fn()
|
||||
vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({
|
||||
useNodesReadOnly: () => ({
|
||||
getNodesReadOnly: mockGetNodesReadOnly,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useSerialAsyncCallback - pass through the callback
|
||||
vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({
|
||||
useSerialAsyncCallback: (callback: (...args: unknown[]) => unknown) => callback,
|
||||
}))
|
||||
|
||||
// Mock workflow store
|
||||
const mockSetSyncWorkflowDraftHash = vi.fn()
|
||||
const mockSetDraftUpdatedAt = vi.fn()
|
||||
|
||||
const createMockWorkflowStoreState = (overrides = {}) => ({
|
||||
appId: 'test-app-id',
|
||||
conversationVariables: [],
|
||||
environmentVariables: [],
|
||||
syncWorkflowDraftHash: 'current-hash-123',
|
||||
isWorkflowDataLoaded: true,
|
||||
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
|
||||
setDraftUpdatedAt: mockSetDraftUpdatedAt,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const mockWorkflowStoreGetState = vi.fn()
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: mockWorkflowStoreGetState,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useWorkflowRefreshDraft (THE KEY DEPENDENCY FOR THIS TEST)
|
||||
const mockHandleRefreshWorkflowDraft = vi.fn()
|
||||
vi.mock('.', () => ({
|
||||
useWorkflowRefreshDraft: () => ({
|
||||
handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock API_PREFIX
|
||||
vi.mock('@/config', () => ({
|
||||
API_PREFIX: '/api',
|
||||
}))
|
||||
|
||||
// Create a mock error response that mimics the actual API error
|
||||
const createMockErrorResponse = (code: string) => {
|
||||
const errorBody = { code, message: 'Draft not in sync' }
|
||||
let bodyUsed = false
|
||||
|
||||
return {
|
||||
json: vi.fn().mockImplementation(() => {
|
||||
bodyUsed = true
|
||||
return Promise.resolve(errorBody)
|
||||
}),
|
||||
get bodyUsed() {
|
||||
return bodyUsed
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('useNodesSyncDraft', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetNodesReadOnly.mockReturnValue(false)
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 'node-1', type: 'start', data: { type: 'start' } },
|
||||
{ id: 'node-2', type: 'llm', data: { type: 'llm' } },
|
||||
])
|
||||
mockStoreState.edges = [
|
||||
{ id: 'edge-1', source: 'node-1', target: 'node-2', data: {} },
|
||||
]
|
||||
mockWorkflowStoreGetState.mockReturnValue(createMockWorkflowStoreState())
|
||||
mockSyncWorkflowDraft.mockResolvedValue({
|
||||
hash: 'new-hash-456',
|
||||
updated_at: Date.now(),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('doSyncWorkflowDraft function', () => {
|
||||
it('should return doSyncWorkflowDraft function', () => {
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
expect(result.current.doSyncWorkflowDraft).toBeDefined()
|
||||
expect(typeof result.current.doSyncWorkflowDraft).toBe('function')
|
||||
})
|
||||
|
||||
it('should return syncWorkflowDraftWhenPageClose function', () => {
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
expect(result.current.syncWorkflowDraftWhenPageClose).toBeDefined()
|
||||
expect(typeof result.current.syncWorkflowDraftWhenPageClose).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('successful sync', () => {
|
||||
it('should call syncWorkflowDraft service on successful sync', async () => {
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({
|
||||
url: '/apps/test-app-id/workflows/draft',
|
||||
params: expect.objectContaining({
|
||||
hash: 'current-hash-123',
|
||||
graph: expect.objectContaining({
|
||||
nodes: expect.any(Array),
|
||||
edges: expect.any(Array),
|
||||
viewport: expect.any(Object),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should update syncWorkflowDraftHash on success', async () => {
|
||||
mockSyncWorkflowDraft.mockResolvedValue({
|
||||
hash: 'new-hash-789',
|
||||
updated_at: 1234567890,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash-789')
|
||||
})
|
||||
|
||||
it('should update draftUpdatedAt on success', async () => {
|
||||
const updatedAt = 1234567890
|
||||
mockSyncWorkflowDraft.mockResolvedValue({
|
||||
hash: 'new-hash',
|
||||
updated_at: updatedAt,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(updatedAt)
|
||||
})
|
||||
|
||||
it('should call onSuccess callback on success', async () => {
|
||||
const onSuccess = vi.fn()
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft(false, { onSuccess })
|
||||
})
|
||||
|
||||
expect(onSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onSettled callback after success', async () => {
|
||||
const onSettled = vi.fn()
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft(false, { onSettled })
|
||||
})
|
||||
|
||||
expect(onSettled).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sync error handling - draft_workflow_not_sync (THE KEY FIX)', () => {
|
||||
/**
|
||||
* This is THE KEY TEST for the bug fix.
|
||||
*
|
||||
* SCENARIO: Multi-tab editing
|
||||
* 1. User opens workflow in Tab A and Tab B
|
||||
* 2. Tab A saves draft successfully, gets new hash
|
||||
* 3. Tab B tries to save with old hash
|
||||
* 4. Server returns 400 with code 'draft_workflow_not_sync'
|
||||
*
|
||||
* BEFORE FIX:
|
||||
* - handleRefreshWorkflowDraft() was called without arguments
|
||||
* - This would fetch draft AND overwrite the canvas
|
||||
* - User loses their unsaved changes in Tab B
|
||||
*
|
||||
* AFTER FIX:
|
||||
* - handleRefreshWorkflowDraft(true) is called
|
||||
* - This fetches draft but DOES NOT overwrite canvas
|
||||
* - Only hash is updated for the next sync attempt
|
||||
* - User's unsaved changes are preserved
|
||||
*/
|
||||
it('should call handleRefreshWorkflowDraft with notUpdateCanvas=true when draft_workflow_not_sync error occurs', async () => {
|
||||
const mockError = createMockErrorResponse('draft_workflow_not_sync')
|
||||
mockSyncWorkflowDraft.mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
// THE KEY ASSERTION: handleRefreshWorkflowDraft must be called with true
|
||||
await waitFor(() => {
|
||||
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should NOT call handleRefreshWorkflowDraft when notRefreshWhenSyncError is true', async () => {
|
||||
const mockError = createMockErrorResponse('draft_workflow_not_sync')
|
||||
mockSyncWorkflowDraft.mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
// First parameter is notRefreshWhenSyncError
|
||||
await result.current.doSyncWorkflowDraft(true)
|
||||
})
|
||||
|
||||
// Wait a bit for async operations
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onError callback when draft_workflow_not_sync error occurs', async () => {
|
||||
const mockError = createMockErrorResponse('draft_workflow_not_sync')
|
||||
mockSyncWorkflowDraft.mockRejectedValue(mockError)
|
||||
const onError = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft(false, { onError })
|
||||
})
|
||||
|
||||
expect(onError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onSettled callback after error', async () => {
|
||||
const mockError = createMockErrorResponse('draft_workflow_not_sync')
|
||||
mockSyncWorkflowDraft.mockRejectedValue(mockError)
|
||||
const onSettled = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft(false, { onSettled })
|
||||
})
|
||||
|
||||
expect(onSettled).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('other error handling', () => {
|
||||
it('should NOT call handleRefreshWorkflowDraft for non-draft_workflow_not_sync errors', async () => {
|
||||
const mockError = createMockErrorResponse('some_other_error')
|
||||
mockSyncWorkflowDraft.mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
// Wait a bit for async operations
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle error without json method', async () => {
|
||||
const mockError = new Error('Network error')
|
||||
mockSyncWorkflowDraft.mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
const onError = vi.fn()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft(false, { onError })
|
||||
})
|
||||
|
||||
expect(onError).toHaveBeenCalled()
|
||||
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle error with bodyUsed already true', async () => {
|
||||
const mockError = {
|
||||
json: vi.fn(),
|
||||
bodyUsed: true,
|
||||
}
|
||||
mockSyncWorkflowDraft.mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
// Should not call json() when bodyUsed is true
|
||||
expect(mockError.json).not.toHaveBeenCalled()
|
||||
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('read-only mode', () => {
|
||||
it('should not sync when nodes are read-only', async () => {
|
||||
mockGetNodesReadOnly.mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not sync on page close when nodes are read-only', () => {
|
||||
mockGetNodesReadOnly.mockReturnValue(true)
|
||||
|
||||
// Mock sendBeacon
|
||||
const mockSendBeacon = vi.fn()
|
||||
Object.defineProperty(navigator, 'sendBeacon', {
|
||||
value: mockSendBeacon,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.syncWorkflowDraftWhenPageClose()
|
||||
})
|
||||
|
||||
expect(mockSendBeacon).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('workflow data not loaded', () => {
|
||||
it('should not sync when workflow data is not loaded', async () => {
|
||||
mockWorkflowStoreGetState.mockReturnValue(
|
||||
createMockWorkflowStoreState({ isWorkflowDataLoaded: false }),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('no appId', () => {
|
||||
it('should not sync when appId is not set', async () => {
|
||||
mockWorkflowStoreGetState.mockReturnValue(
|
||||
createMockWorkflowStoreState({ appId: null }),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('node filtering', () => {
|
||||
it('should filter out temp nodes', async () => {
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 'node-1', type: 'start', data: { type: 'start' } },
|
||||
{ id: 'node-temp', type: 'custom', data: { type: 'custom', _isTempNode: true } },
|
||||
{ id: 'node-2', type: 'llm', data: { type: 'llm' } },
|
||||
])
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
graph: expect.objectContaining({
|
||||
nodes: expect.not.arrayContaining([
|
||||
expect.objectContaining({ id: 'node-temp' }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should remove internal underscore properties from nodes', async () => {
|
||||
mockGetNodes.mockReturnValue([
|
||||
{
|
||||
id: 'node-1',
|
||||
type: 'start',
|
||||
data: {
|
||||
type: 'start',
|
||||
_internalProp: 'should be removed',
|
||||
_anotherInternal: true,
|
||||
publicProp: 'should remain',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
const callArgs = mockSyncWorkflowDraft.mock.calls[0][0]
|
||||
const sentNode = callArgs.params.graph.nodes[0]
|
||||
|
||||
expect(sentNode.data).not.toHaveProperty('_internalProp')
|
||||
expect(sentNode.data).not.toHaveProperty('_anotherInternal')
|
||||
expect(sentNode.data).toHaveProperty('publicProp', 'should remain')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge filtering', () => {
|
||||
it('should filter out temp edges', async () => {
|
||||
mockStoreState.edges = [
|
||||
{ id: 'edge-1', source: 'node-1', target: 'node-2', data: {} },
|
||||
{ id: 'edge-temp', source: 'node-1', target: 'node-3', data: { _isTemp: true } },
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
const callArgs = mockSyncWorkflowDraft.mock.calls[0][0]
|
||||
const sentEdges = callArgs.params.graph.edges
|
||||
|
||||
expect(sentEdges).toHaveLength(1)
|
||||
expect(sentEdges[0].id).toBe('edge-1')
|
||||
})
|
||||
|
||||
it('should remove internal underscore properties from edges', async () => {
|
||||
mockStoreState.edges = [
|
||||
{
|
||||
id: 'edge-1',
|
||||
source: 'node-1',
|
||||
target: 'node-2',
|
||||
data: {
|
||||
_internalEdgeProp: 'should be removed',
|
||||
publicEdgeProp: 'should remain',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
const callArgs = mockSyncWorkflowDraft.mock.calls[0][0]
|
||||
const sentEdge = callArgs.params.graph.edges[0]
|
||||
|
||||
expect(sentEdge.data).not.toHaveProperty('_internalEdgeProp')
|
||||
expect(sentEdge.data).toHaveProperty('publicEdgeProp', 'should remain')
|
||||
})
|
||||
})
|
||||
|
||||
describe('viewport handling', () => {
|
||||
it('should send current viewport from transform', async () => {
|
||||
mockStoreState.transform = [100, 200, 1.5]
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
graph: expect.objectContaining({
|
||||
viewport: { x: 100, y: 200, zoom: 1.5 },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi-tab concurrent editing scenario (END-TO-END TEST)', () => {
|
||||
/**
|
||||
* Simulates the complete multi-tab scenario to verify the fix works correctly.
|
||||
*
|
||||
* Scenario:
|
||||
* 1. Tab A and Tab B both have the workflow open with hash 'hash-v1'
|
||||
* 2. Tab A saves successfully, server returns 'hash-v2'
|
||||
* 3. Tab B tries to save with 'hash-v1', gets 'draft_workflow_not_sync' error
|
||||
* 4. Tab B should only update hash to 'hash-v2', not overwrite canvas
|
||||
* 5. Tab B can now retry save with correct hash
|
||||
*/
|
||||
it('should preserve canvas data during hash conflict resolution', async () => {
|
||||
// Initial state: both tabs have hash-v1
|
||||
mockWorkflowStoreGetState.mockReturnValue(
|
||||
createMockWorkflowStoreState({ syncWorkflowDraftHash: 'hash-v1' }),
|
||||
)
|
||||
|
||||
// Tab B tries to save with old hash, server returns error
|
||||
const syncError = createMockErrorResponse('draft_workflow_not_sync')
|
||||
mockSyncWorkflowDraft.mockRejectedValue(syncError)
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
// Tab B attempts to sync
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
// Verify the sync was attempted with old hash
|
||||
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
hash: 'hash-v1',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
// Verify handleRefreshWorkflowDraft was called with true (not overwrite canvas)
|
||||
await waitFor(() => {
|
||||
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
// The key assertion: only one argument (true) was passed
|
||||
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleRefreshWorkflowDraft.mock.calls[0]).toEqual([true])
|
||||
})
|
||||
|
||||
it('should handle multiple consecutive sync failures gracefully', async () => {
|
||||
// Create fresh error for each call to avoid bodyUsed issue
|
||||
mockSyncWorkflowDraft
|
||||
.mockRejectedValueOnce(createMockErrorResponse('draft_workflow_not_sync'))
|
||||
.mockRejectedValueOnce(createMockErrorResponse('draft_workflow_not_sync'))
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
// First sync attempt
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
// Wait for first refresh call
|
||||
await waitFor(() => {
|
||||
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Second sync attempt
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
// Both should call handleRefreshWorkflowDraft with true
|
||||
await waitFor(() => {
|
||||
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
mockHandleRefreshWorkflowDraft.mock.calls.forEach((call) => {
|
||||
expect(call).toEqual([true])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('callbacks behavior', () => {
|
||||
it('should not call onSuccess when sync fails', async () => {
|
||||
const syncError = createMockErrorResponse('draft_workflow_not_sync')
|
||||
mockSyncWorkflowDraft.mockRejectedValue(syncError)
|
||||
const onSuccess = vi.fn()
|
||||
const onError = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft(false, { onSuccess, onError })
|
||||
})
|
||||
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
expect(onError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should always call onSettled regardless of success or failure', async () => {
|
||||
const onSettled = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
// Test success case
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft(false, { onSettled })
|
||||
})
|
||||
expect(onSettled).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Reset
|
||||
onSettled.mockClear()
|
||||
|
||||
// Test failure case
|
||||
const syncError = createMockErrorResponse('draft_workflow_not_sync')
|
||||
mockSyncWorkflowDraft.mockRejectedValue(syncError)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft(false, { onSettled })
|
||||
})
|
||||
expect(onSettled).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -115,7 +115,7 @@ export const useNodesSyncDraft = () => {
|
||||
if (error && error.json && !error.bodyUsed) {
|
||||
error.json().then((err: any) => {
|
||||
if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError)
|
||||
handleRefreshWorkflowDraft(true)
|
||||
handleRefreshWorkflowDraft()
|
||||
})
|
||||
}
|
||||
callback?.onError?.()
|
||||
|
||||
@@ -1,556 +0,0 @@
|
||||
/**
|
||||
* Test Suite for useWorkflowRefreshDraft Hook
|
||||
*
|
||||
* PURPOSE:
|
||||
* This hook is responsible for refreshing workflow draft data from the server.
|
||||
* The key fix being tested is the `notUpdateCanvas` parameter behavior.
|
||||
*
|
||||
* MULTI-TAB PROBLEM SCENARIO:
|
||||
* 1. User opens the same workflow in Tab A and Tab B (both have hash: v1)
|
||||
* 2. Tab A saves successfully, server returns new hash: v2
|
||||
* 3. Tab B tries to save with old hash: v1, server returns 400 error (draft_workflow_not_sync)
|
||||
* 4. BEFORE FIX: handleRefreshWorkflowDraft() was called without args, which fetched
|
||||
* draft AND overwrote canvas - user lost unsaved changes in Tab B
|
||||
* 5. AFTER FIX: handleRefreshWorkflowDraft(true) is called, which fetches draft but
|
||||
* only updates hash, preserving user's canvas changes
|
||||
*
|
||||
* TESTING STRATEGY:
|
||||
* We don't simulate actual tab switching UI behavior. Instead, we test the hook's
|
||||
* response to specific inputs:
|
||||
* - When notUpdateCanvas=true: should NOT call handleUpdateWorkflowCanvas
|
||||
* - When notUpdateCanvas=false/undefined: should call handleUpdateWorkflowCanvas
|
||||
*
|
||||
* This is behavior-driven testing - we verify "what the code does when given specific
|
||||
* inputs" rather than simulating complete user interaction flows.
|
||||
*/
|
||||
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useWorkflowRefreshDraft } from './use-workflow-refresh-draft'
|
||||
|
||||
// Mock the workflow service
|
||||
const mockFetchWorkflowDraft = vi.fn()
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args),
|
||||
}))
|
||||
|
||||
// Mock the workflow update hook
|
||||
const mockHandleUpdateWorkflowCanvas = vi.fn()
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useWorkflowUpdate: () => ({
|
||||
handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock store state
|
||||
const mockSetSyncWorkflowDraftHash = vi.fn()
|
||||
const mockSetIsSyncingWorkflowDraft = vi.fn()
|
||||
const mockSetEnvironmentVariables = vi.fn()
|
||||
const mockSetEnvSecrets = vi.fn()
|
||||
const mockSetConversationVariables = vi.fn()
|
||||
const mockSetIsWorkflowDataLoaded = vi.fn()
|
||||
const mockCancelDebouncedSync = vi.fn()
|
||||
|
||||
const createMockStoreState = (overrides = {}) => ({
|
||||
appId: 'test-app-id',
|
||||
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
|
||||
setIsSyncingWorkflowDraft: mockSetIsSyncingWorkflowDraft,
|
||||
setEnvironmentVariables: mockSetEnvironmentVariables,
|
||||
setEnvSecrets: mockSetEnvSecrets,
|
||||
setConversationVariables: mockSetConversationVariables,
|
||||
setIsWorkflowDataLoaded: mockSetIsWorkflowDataLoaded,
|
||||
isWorkflowDataLoaded: true,
|
||||
debouncedSyncWorkflowDraft: {
|
||||
cancel: mockCancelDebouncedSync,
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const mockWorkflowStoreGetState = vi.fn()
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: mockWorkflowStoreGetState,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Default mock response from fetchWorkflowDraft
|
||||
const createMockDraftResponse = (overrides = {}) => ({
|
||||
hash: 'new-hash-12345',
|
||||
graph: {
|
||||
nodes: [{ id: 'node-1', type: 'start', data: {} }],
|
||||
edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }],
|
||||
viewport: { x: 100, y: 200, zoom: 1.5 },
|
||||
},
|
||||
environment_variables: [
|
||||
{ id: 'env-1', name: 'API_KEY', value: 'secret-key', value_type: 'secret' },
|
||||
{ id: 'env-2', name: 'BASE_URL', value: 'https://api.example.com', value_type: 'string' },
|
||||
],
|
||||
conversation_variables: [
|
||||
{ id: 'conv-1', name: 'user_input', value: 'test' },
|
||||
],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('useWorkflowRefreshDraft', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkflowStoreGetState.mockReturnValue(createMockStoreState())
|
||||
mockFetchWorkflowDraft.mockResolvedValue(createMockDraftResponse())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('handleRefreshWorkflowDraft function', () => {
|
||||
it('should return handleRefreshWorkflowDraft function', () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
expect(result.current.handleRefreshWorkflowDraft).toBeDefined()
|
||||
expect(typeof result.current.handleRefreshWorkflowDraft).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('notUpdateCanvas parameter behavior (THE KEY FIX)', () => {
|
||||
it('should NOT call handleUpdateWorkflowCanvas when notUpdateCanvas is true', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/apps/test-app-id/workflows/draft')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash-12345')
|
||||
})
|
||||
|
||||
// THE KEY ASSERTION: Canvas should NOT be updated when notUpdateCanvas is true
|
||||
expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call handleUpdateWorkflowCanvas when notUpdateCanvas is false', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(false)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/apps/test-app-id/workflows/draft')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
// Canvas SHOULD be updated when notUpdateCanvas is false
|
||||
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
|
||||
nodes: [{ id: 'node-1', type: 'start', data: {} }],
|
||||
edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }],
|
||||
viewport: { x: 100, y: 200, zoom: 1.5 },
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash-12345')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call handleUpdateWorkflowCanvas when notUpdateCanvas is undefined (default)', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
// Canvas SHOULD be updated when notUpdateCanvas is undefined
|
||||
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should still update hash even when notUpdateCanvas is true', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash-12345')
|
||||
})
|
||||
|
||||
// Verify canvas was NOT updated
|
||||
expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should still update environment variables when notUpdateCanvas is true', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([
|
||||
{ id: 'env-1', name: 'API_KEY', value: '[__HIDDEN__]', value_type: 'secret' },
|
||||
{ id: 'env-2', name: 'BASE_URL', value: 'https://api.example.com', value_type: 'string' },
|
||||
])
|
||||
})
|
||||
|
||||
expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should still update env secrets when notUpdateCanvas is true', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetEnvSecrets).toHaveBeenCalledWith({
|
||||
'env-1': 'secret-key',
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should still update conversation variables when notUpdateCanvas is true', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetConversationVariables).toHaveBeenCalledWith([
|
||||
{ id: 'conv-1', name: 'user_input', value: 'test' },
|
||||
])
|
||||
})
|
||||
|
||||
expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncing state management', () => {
|
||||
it('should set isSyncingWorkflowDraft to true before fetch', () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should set isSyncingWorkflowDraft to false after fetch completes', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should set isSyncingWorkflowDraft to false even when fetch fails', async () => {
|
||||
mockFetchWorkflowDraft.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isWorkflowDataLoaded flag management', () => {
|
||||
it('should set isWorkflowDataLoaded to false before fetch when it was true', () => {
|
||||
mockWorkflowStoreGetState.mockReturnValue(
|
||||
createMockStoreState({ isWorkflowDataLoaded: true }),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockSetIsWorkflowDataLoaded).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should set isWorkflowDataLoaded to true after fetch succeeds', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetIsWorkflowDataLoaded).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should restore isWorkflowDataLoaded when fetch fails and it was previously loaded', async () => {
|
||||
mockWorkflowStoreGetState.mockReturnValue(
|
||||
createMockStoreState({ isWorkflowDataLoaded: true }),
|
||||
)
|
||||
mockFetchWorkflowDraft.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
// Should restore to true because wasLoaded was true
|
||||
expect(mockSetIsWorkflowDataLoaded).toHaveBeenLastCalledWith(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('debounced sync cancellation', () => {
|
||||
it('should cancel debounced sync before fetching draft', () => {
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockCancelDebouncedSync).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle case when debouncedSyncWorkflowDraft has no cancel method', () => {
|
||||
mockWorkflowStoreGetState.mockReturnValue(
|
||||
createMockStoreState({ debouncedSyncWorkflowDraft: {} }),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
// Should not throw
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft()
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty graph in response', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
hash: 'hash-empty',
|
||||
graph: null,
|
||||
environment_variables: [],
|
||||
conversation_variables: [],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(false)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle missing viewport in response', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
hash: 'hash-no-viewport',
|
||||
graph: {
|
||||
nodes: [{ id: 'node-1' }],
|
||||
edges: [],
|
||||
viewport: null,
|
||||
},
|
||||
environment_variables: [],
|
||||
conversation_variables: [],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(false)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
|
||||
nodes: [{ id: 'node-1' }],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle missing environment_variables in response', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
hash: 'hash-no-env',
|
||||
graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
|
||||
environment_variables: undefined,
|
||||
conversation_variables: [],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([])
|
||||
expect(mockSetEnvSecrets).toHaveBeenCalledWith({})
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle missing conversation_variables in response', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
hash: 'hash-no-conv',
|
||||
graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
|
||||
environment_variables: [],
|
||||
conversation_variables: undefined,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetConversationVariables).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
it('should filter only secret type for envSecrets', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
hash: 'hash-mixed-env',
|
||||
graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
|
||||
environment_variables: [
|
||||
{ id: 'env-1', name: 'SECRET_KEY', value: 'secret-value', value_type: 'secret' },
|
||||
{ id: 'env-2', name: 'PUBLIC_URL', value: 'https://example.com', value_type: 'string' },
|
||||
{ id: 'env-3', name: 'ANOTHER_SECRET', value: 'another-secret', value_type: 'secret' },
|
||||
],
|
||||
conversation_variables: [],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetEnvSecrets).toHaveBeenCalledWith({
|
||||
'env-1': 'secret-value',
|
||||
'env-3': 'another-secret',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide secret values in environment variables', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
hash: 'hash-secrets',
|
||||
graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
|
||||
environment_variables: [
|
||||
{ id: 'env-1', name: 'SECRET_KEY', value: 'super-secret', value_type: 'secret' },
|
||||
{ id: 'env-2', name: 'PUBLIC_URL', value: 'https://example.com', value_type: 'string' },
|
||||
],
|
||||
conversation_variables: [],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([
|
||||
{ id: 'env-1', name: 'SECRET_KEY', value: '[__HIDDEN__]', value_type: 'secret' },
|
||||
{ id: 'env-2', name: 'PUBLIC_URL', value: 'https://example.com', value_type: 'string' },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi-tab scenario simulation (THE BUG FIX VERIFICATION)', () => {
|
||||
/**
|
||||
* This test verifies the fix for the multi-tab scenario:
|
||||
* 1. User opens workflow in Tab A and Tab B
|
||||
* 2. Tab A saves draft successfully
|
||||
* 3. Tab B tries to save but gets 'draft_workflow_not_sync' error (hash mismatch)
|
||||
* 4. BEFORE FIX: Tab B would fetch draft and overwrite canvas with old data
|
||||
* 5. AFTER FIX: Tab B only updates hash, preserving user's canvas changes
|
||||
*/
|
||||
it('should only update hash when called with notUpdateCanvas=true (simulating sync error recovery)', async () => {
|
||||
const mockResponse = createMockDraftResponse()
|
||||
mockFetchWorkflowDraft.mockResolvedValue(mockResponse)
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
// Simulate the sync error recovery scenario where notUpdateCanvas is true
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
// Hash should be updated for next sync attempt
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash-12345')
|
||||
})
|
||||
|
||||
// Canvas should NOT be updated - user's changes are preserved
|
||||
expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
|
||||
|
||||
// Other states should still be updated
|
||||
expect(mockSetEnvironmentVariables).toHaveBeenCalled()
|
||||
expect(mockSetConversationVariables).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update canvas when called with notUpdateCanvas=false (normal refresh)', async () => {
|
||||
const mockResponse = createMockDraftResponse()
|
||||
mockFetchWorkflowDraft.mockResolvedValue(mockResponse)
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
// Simulate normal refresh scenario
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft(false)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash-12345')
|
||||
})
|
||||
|
||||
// Canvas SHOULD be updated in normal refresh
|
||||
await waitFor(() => {
|
||||
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@ export const useWorkflowRefreshDraft = () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
|
||||
|
||||
const handleRefreshWorkflowDraft = useCallback((notUpdateCanvas?: boolean) => {
|
||||
const handleRefreshWorkflowDraft = useCallback(() => {
|
||||
const {
|
||||
appId,
|
||||
setSyncWorkflowDraftHash,
|
||||
@@ -31,14 +31,12 @@ export const useWorkflowRefreshDraft = () => {
|
||||
fetchWorkflowDraft(`/apps/${appId}/workflows/draft`)
|
||||
.then((response) => {
|
||||
// Ensure we have a valid workflow structure with viewport
|
||||
if (!notUpdateCanvas) {
|
||||
const workflowData: WorkflowDataUpdater = {
|
||||
nodes: response.graph?.nodes || [],
|
||||
edges: response.graph?.edges || [],
|
||||
viewport: response.graph?.viewport || { x: 0, y: 0, zoom: 1 },
|
||||
}
|
||||
handleUpdateWorkflowCanvas(workflowData)
|
||||
const workflowData: WorkflowDataUpdater = {
|
||||
nodes: response.graph?.nodes || [],
|
||||
edges: response.graph?.edges || [],
|
||||
viewport: response.graph?.viewport || { x: 0, y: 0, zoom: 1 },
|
||||
}
|
||||
handleUpdateWorkflowCanvas(workflowData)
|
||||
setSyncWorkflowDraftHash(response.hash)
|
||||
setEnvSecrets((response.environment_variables || []).filter(env => env.value_type === 'secret').reduce((acc, env) => {
|
||||
acc[env.id] = env.value
|
||||
|
||||
@@ -2150,6 +2150,16 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/goto-anything/index.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"app/components/goto-anything/index.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/header/account-setting/data-source-page-new/card.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { oc } from '@orpc/contract'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { zAudioToTextData, zAudioToTextResponse2, zCreateAnnotationData, zCreateAnnotationResponse, zDeleteAnnotationData, zDeleteConversationData, zGetAnnotationListData, zGetAnnotationListResponse, zGetChatAppFeedbacksData, zGetChatAppFeedbacksResponse, zGetChatAppInfoResponse, zGetChatAppMetaResponse, zGetChatAppParametersData, zGetChatAppParametersResponse, zGetChatWebAppSettingsResponse, zGetConversationHistoryData, zGetConversationHistoryResponse, zGetConversationsListData, zGetConversationsListResponse, zGetConversationVariablesData, zGetConversationVariablesResponse, zGetInitialAnnotationReplySettingsStatusData, zGetInitialAnnotationReplySettingsStatusResponse, zGetSuggestedQuestionsData, zGetSuggestedQuestionsResponse, zInitialAnnotationReplySettingsData, zInitialAnnotationReplySettingsResponse2, zPostChatMessageFeedbackData, zPostChatMessageFeedbackResponse, zPreviewChatFileData, zPreviewChatFileResponse, zRenameConversationData, zRenameConversationResponse, zSendChatMessageData, zSendChatMessageResponse, zStopChatMessageGenerationData, zStopChatMessageGenerationResponse, zTextToAudioChatData, zTextToAudioChatResponse, zUpdateAnnotationData, zUpdateAnnotationResponse, zUploadChatFileData, zUploadChatFileResponse } from './zod'
|
||||
|
||||
export const base = oc.$route({ inputStructure: 'detailed', outputStructure: 'detailed' })
|
||||
|
||||
/**
|
||||
* Send Chat Message
|
||||
*
|
||||
* Send a request to the chat application.
|
||||
*/
|
||||
export const sendChatMessageContract = base.route({
|
||||
method: 'POST',
|
||||
path: '/chat-messages',
|
||||
operationId: 'sendChatMessage',
|
||||
summary: 'Send Chat Message',
|
||||
description: 'Send a request to the chat application.',
|
||||
tags: ['Chat'],
|
||||
}).input(zSendChatMessageData).output(z.object({ body: zSendChatMessageResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* File Upload
|
||||
*
|
||||
* Upload a file (currently only images are supported) for use when sending messages, enabling multimodal understanding of images and text. Supports png, jpg, jpeg, webp, gif formats. Uploaded files are for use by the current end-user only.
|
||||
*/
|
||||
export const uploadChatFileContract = base.route({
|
||||
method: 'POST',
|
||||
path: '/files/upload',
|
||||
operationId: 'uploadChatFile',
|
||||
summary: 'File Upload',
|
||||
description: 'Upload a file (currently only images are supported) for use when sending messages, enabling multimodal understanding of images and text. Supports png, jpg, jpeg, webp, gif formats. Uploaded files are for use by the current end-user only.',
|
||||
tags: ['Files'],
|
||||
}).input(zUploadChatFileData).output(z.object({ body: zUploadChatFileResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* File Preview
|
||||
*
|
||||
* Preview or download uploaded files. This endpoint allows you to access files that have been previously uploaded via the File Upload API. Files can only be accessed if they belong to messages within the requesting application.
|
||||
*/
|
||||
export const previewChatFileContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/files/{file_id}/preview',
|
||||
operationId: 'previewChatFile',
|
||||
summary: 'File Preview',
|
||||
description: 'Preview or download uploaded files. This endpoint allows you to access files that have been previously uploaded via the File Upload API. Files can only be accessed if they belong to messages within the requesting application.',
|
||||
tags: ['Files'],
|
||||
}).input(zPreviewChatFileData).output(z.object({ body: zPreviewChatFileResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Stop Chat Message Generation
|
||||
*
|
||||
* Stops a chat message generation task. Only supported in streaming mode.
|
||||
*/
|
||||
export const stopChatMessageGenerationContract = base.route({
|
||||
method: 'POST',
|
||||
path: '/chat-messages/{task_id}/stop',
|
||||
operationId: 'stopChatMessageGeneration',
|
||||
summary: 'Stop Chat Message Generation',
|
||||
description: 'Stops a chat message generation task. Only supported in streaming mode.',
|
||||
tags: ['Chat'],
|
||||
}).input(zStopChatMessageGenerationData).output(z.object({ body: zStopChatMessageGenerationResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Message Feedback
|
||||
*
|
||||
* End-users can provide feedback messages, facilitating application developers to optimize expected outputs.
|
||||
*/
|
||||
export const postChatMessageFeedbackContract = base.route({
|
||||
method: 'POST',
|
||||
path: '/messages/{message_id}/feedbacks',
|
||||
operationId: 'postChatMessageFeedback',
|
||||
summary: 'Message Feedback',
|
||||
description: 'End-users can provide feedback messages, facilitating application developers to optimize expected outputs.',
|
||||
tags: ['Feedback'],
|
||||
}).input(zPostChatMessageFeedbackData).output(z.object({ body: zPostChatMessageFeedbackResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Get feedbacks of application
|
||||
*
|
||||
* Get application's feedbacks.
|
||||
*/
|
||||
export const getChatAppFeedbacksContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/app/feedbacks',
|
||||
operationId: 'getChatAppFeedbacks',
|
||||
summary: 'Get feedbacks of application',
|
||||
description: 'Get application\'s feedbacks.',
|
||||
tags: ['Feedback'],
|
||||
}).input(zGetChatAppFeedbacksData).output(z.object({ body: zGetChatAppFeedbacksResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Next Suggested Questions
|
||||
*
|
||||
* Get next questions suggestions for the current message.
|
||||
*/
|
||||
export const getSuggestedQuestionsContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/messages/{message_id}/suggested',
|
||||
operationId: 'getSuggestedQuestions',
|
||||
summary: 'Next Suggested Questions',
|
||||
description: 'Get next questions suggestions for the current message.',
|
||||
tags: ['Chat'],
|
||||
}).input(zGetSuggestedQuestionsData).output(z.object({ body: zGetSuggestedQuestionsResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Get Conversation History Messages
|
||||
*
|
||||
* Returns historical chat records in a scrolling load format, with the first page returning the latest `{limit}` messages, i.e., in reverse order.
|
||||
*/
|
||||
export const getConversationHistoryContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/messages',
|
||||
operationId: 'getConversationHistory',
|
||||
summary: 'Get Conversation History Messages',
|
||||
description: 'Returns historical chat records in a scrolling load format, with the first page returning the latest `{limit}` messages, i.e., in reverse order.',
|
||||
tags: ['Conversations'],
|
||||
}).input(zGetConversationHistoryData).output(z.object({ body: zGetConversationHistoryResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Get Conversations
|
||||
*
|
||||
* Retrieve the conversation list for the current user, defaulting to the most recent 20 entries.
|
||||
*/
|
||||
export const getConversationsListContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/conversations',
|
||||
operationId: 'getConversationsList',
|
||||
summary: 'Get Conversations',
|
||||
description: 'Retrieve the conversation list for the current user, defaulting to the most recent 20 entries.',
|
||||
tags: ['Conversations'],
|
||||
}).input(zGetConversationsListData).output(z.object({ body: zGetConversationsListResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Delete Conversation
|
||||
*
|
||||
* Delete a conversation.
|
||||
*/
|
||||
export const deleteConversationContract = base.route({
|
||||
method: 'DELETE',
|
||||
path: '/conversations/{conversation_id}',
|
||||
operationId: 'deleteConversation',
|
||||
summary: 'Delete Conversation',
|
||||
description: 'Delete a conversation.',
|
||||
tags: ['Conversations'],
|
||||
}).input(zDeleteConversationData)
|
||||
|
||||
/**
|
||||
* Conversation Rename
|
||||
*
|
||||
* Rename the session. The session name is used for display on clients that support multiple sessions.
|
||||
*/
|
||||
export const renameConversationContract = base.route({
|
||||
method: 'POST',
|
||||
path: '/conversations/{conversation_id}/name',
|
||||
operationId: 'renameConversation',
|
||||
summary: 'Conversation Rename',
|
||||
description: 'Rename the session. The session name is used for display on clients that support multiple sessions.',
|
||||
tags: ['Conversations'],
|
||||
}).input(zRenameConversationData).output(z.object({ body: zRenameConversationResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Get Conversation Variables
|
||||
*
|
||||
* Retrieve variables from a specific conversation.
|
||||
*/
|
||||
export const getConversationVariablesContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/conversations/{conversation_id}/variables',
|
||||
operationId: 'getConversationVariables',
|
||||
summary: 'Get Conversation Variables',
|
||||
description: 'Retrieve variables from a specific conversation.',
|
||||
tags: ['Conversations'],
|
||||
}).input(zGetConversationVariablesData).output(z.object({ body: zGetConversationVariablesResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Speech to Text
|
||||
*
|
||||
* Convert audio file to text. Supported formats: mp3, mp4, mpeg, mpga, m4a, wav, webm. File size limit: 15MB.
|
||||
*/
|
||||
export const audioToTextContract = base.route({
|
||||
method: 'POST',
|
||||
path: '/audio-to-text',
|
||||
operationId: 'audioToText',
|
||||
summary: 'Speech to Text',
|
||||
description: 'Convert audio file to text. Supported formats: mp3, mp4, mpeg, mpga, m4a, wav, webm. File size limit: 15MB.',
|
||||
tags: ['TTS'],
|
||||
}).input(zAudioToTextData).output(z.object({ body: zAudioToTextResponse2, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Text to Audio
|
||||
*
|
||||
* Convert text to speech.
|
||||
*/
|
||||
export const textToAudioChatContract = base.route({
|
||||
method: 'POST',
|
||||
path: '/text-to-audio',
|
||||
operationId: 'textToAudioChat',
|
||||
summary: 'Text to Audio',
|
||||
description: 'Convert text to speech.',
|
||||
tags: ['TTS'],
|
||||
}).input(zTextToAudioChatData).output(z.object({ body: zTextToAudioChatResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Get Application Basic Information
|
||||
*
|
||||
* Used to get basic information about this application.
|
||||
*/
|
||||
export const getChatAppInfoContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/info',
|
||||
operationId: 'getChatAppInfo',
|
||||
summary: 'Get Application Basic Information',
|
||||
description: 'Used to get basic information about this application.',
|
||||
tags: ['Application'],
|
||||
}).output(z.object({ body: zGetChatAppInfoResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Get Application Parameters Information
|
||||
*
|
||||
* Used at the start of entering the page to obtain information such as features, input parameter names, types, and default values.
|
||||
*/
|
||||
export const getChatAppParametersContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/parameters',
|
||||
operationId: 'getChatAppParameters',
|
||||
summary: 'Get Application Parameters Information',
|
||||
description: 'Used at the start of entering the page to obtain information such as features, input parameter names, types, and default values.',
|
||||
tags: ['Application'],
|
||||
}).input(zGetChatAppParametersData).output(z.object({ body: zGetChatAppParametersResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Get Application Meta Information
|
||||
*
|
||||
* Used to get icons of tools in this application.
|
||||
*/
|
||||
export const getChatAppMetaContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/meta',
|
||||
operationId: 'getChatAppMeta',
|
||||
summary: 'Get Application Meta Information',
|
||||
description: 'Used to get icons of tools in this application.',
|
||||
tags: ['Application'],
|
||||
}).output(z.object({ body: zGetChatAppMetaResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Get Application WebApp Settings
|
||||
*
|
||||
* Used to get the WebApp settings of the application.
|
||||
*/
|
||||
export const getChatWebAppSettingsContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/site',
|
||||
operationId: 'getChatWebAppSettings',
|
||||
summary: 'Get Application WebApp Settings',
|
||||
description: 'Used to get the WebApp settings of the application.',
|
||||
tags: ['Application'],
|
||||
}).output(z.object({ body: zGetChatWebAppSettingsResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Get Annotation List
|
||||
*
|
||||
* Retrieves a list of annotations for the application.
|
||||
*/
|
||||
export const getAnnotationListContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/apps/annotations',
|
||||
operationId: 'getAnnotationList',
|
||||
summary: 'Get Annotation List',
|
||||
description: 'Retrieves a list of annotations for the application.',
|
||||
tags: ['Annotations'],
|
||||
}).input(zGetAnnotationListData).output(z.object({ body: zGetAnnotationListResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Create Annotation
|
||||
*
|
||||
* Creates a new annotation.
|
||||
*/
|
||||
export const createAnnotationContract = base.route({
|
||||
method: 'POST',
|
||||
path: '/apps/annotations',
|
||||
operationId: 'createAnnotation',
|
||||
summary: 'Create Annotation',
|
||||
description: 'Creates a new annotation.',
|
||||
tags: ['Annotations'],
|
||||
}).input(zCreateAnnotationData).output(z.object({ body: zCreateAnnotationResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Delete Annotation
|
||||
*
|
||||
* Deletes an annotation.
|
||||
*/
|
||||
export const deleteAnnotationContract = base.route({
|
||||
method: 'DELETE',
|
||||
path: '/apps/annotations/{annotation_id}',
|
||||
operationId: 'deleteAnnotation',
|
||||
summary: 'Delete Annotation',
|
||||
description: 'Deletes an annotation.',
|
||||
tags: ['Annotations'],
|
||||
}).input(zDeleteAnnotationData)
|
||||
|
||||
/**
|
||||
* Update Annotation
|
||||
*
|
||||
* Updates an existing annotation.
|
||||
*/
|
||||
export const updateAnnotationContract = base.route({
|
||||
method: 'PUT',
|
||||
path: '/apps/annotations/{annotation_id}',
|
||||
operationId: 'updateAnnotation',
|
||||
summary: 'Update Annotation',
|
||||
description: 'Updates an existing annotation.',
|
||||
tags: ['Annotations'],
|
||||
}).input(zUpdateAnnotationData).output(z.object({ body: zUpdateAnnotationResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Initial Annotation Reply Settings
|
||||
*
|
||||
* Enable or disable annotation reply settings and configure embedding models. This interface is executed asynchronously.
|
||||
*/
|
||||
export const initialAnnotationReplySettingsContract = base.route({
|
||||
method: 'POST',
|
||||
path: '/apps/annotation-reply/{action}',
|
||||
operationId: 'initialAnnotationReplySettings',
|
||||
summary: 'Initial Annotation Reply Settings',
|
||||
description: 'Enable or disable annotation reply settings and configure embedding models. This interface is executed asynchronously.',
|
||||
tags: ['Annotations'],
|
||||
}).input(zInitialAnnotationReplySettingsData).output(z.object({ body: zInitialAnnotationReplySettingsResponse2, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Query Initial Annotation Reply Settings Task Status
|
||||
*
|
||||
* Queries the status of an asynchronously executed annotation reply settings task.
|
||||
*/
|
||||
export const getInitialAnnotationReplySettingsStatusContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/apps/annotation-reply/{action}/status/{job_id}',
|
||||
operationId: 'getInitialAnnotationReplySettingsStatus',
|
||||
summary: 'Query Initial Annotation Reply Settings Task Status',
|
||||
description: 'Queries the status of an asynchronously executed annotation reply settings task.',
|
||||
tags: ['Annotations'],
|
||||
}).input(zGetInitialAnnotationReplySettingsStatusData).output(z.object({ body: zGetInitialAnnotationReplySettingsStatusResponse, status: z.literal(200) }))
|
||||
|
||||
export const router = {
|
||||
chatMessages: { send: sendChatMessageContract, stopGeneration: stopChatMessageGenerationContract },
|
||||
files: { uploadChat: uploadChatFileContract, previewChat: previewChatFileContract },
|
||||
messages: {
|
||||
postChatFeedback: postChatMessageFeedbackContract,
|
||||
getSuggestedQuestions: getSuggestedQuestionsContract,
|
||||
getConversationHistory: getConversationHistoryContract,
|
||||
},
|
||||
app: { getChatFeedbacks: getChatAppFeedbacksContract },
|
||||
conversations: {
|
||||
getList: getConversationsListContract,
|
||||
delete: deleteConversationContract,
|
||||
rename: renameConversationContract,
|
||||
getVariables: getConversationVariablesContract,
|
||||
},
|
||||
audioToText: { audioToText: audioToTextContract },
|
||||
textToAudio: { textToAudioChat: textToAudioChatContract },
|
||||
info: { getChatApp: getChatAppInfoContract },
|
||||
parameters: { getChatApp: getChatAppParametersContract },
|
||||
meta: { getChatApp: getChatAppMetaContract },
|
||||
site: { getChatWebAppSettings: getChatWebAppSettingsContract },
|
||||
apps: {
|
||||
getAnnotationList: getAnnotationListContract,
|
||||
createAnnotation: createAnnotationContract,
|
||||
deleteAnnotation: deleteAnnotationContract,
|
||||
updateAnnotation: updateAnnotationContract,
|
||||
initialAnnotationReplySettings: initialAnnotationReplySettingsContract,
|
||||
getInitialAnnotationReplySettingsStatus: getInitialAnnotationReplySettingsStatusContract,
|
||||
},
|
||||
}
|
||||
|
||||
export type Router = typeof router
|
||||
@@ -0,0 +1,20 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { zGetChatAppFeedbacksData, zGetChatAppFeedbacksResponse } from '../../zod/api/app'
|
||||
import { base } from '../common'
|
||||
|
||||
/**
|
||||
* Get feedbacks of application
|
||||
*
|
||||
* Get application's feedbacks.
|
||||
*/
|
||||
export const getChatAppFeedbacksContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/app/feedbacks',
|
||||
operationId: 'getChatAppFeedbacks',
|
||||
summary: 'Get feedbacks of application',
|
||||
description: 'Get application\'s feedbacks.',
|
||||
tags: ['Feedback'],
|
||||
}).input(zGetChatAppFeedbacksData).output(z.object({ body: zGetChatAppFeedbacksResponse, status: z.literal(200) }))
|
||||
@@ -0,0 +1,90 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { zCreateAnnotationData, zCreateAnnotationResponse, zDeleteAnnotationData, zGetAnnotationListData, zGetAnnotationListResponse, zGetInitialAnnotationReplySettingsStatusData, zGetInitialAnnotationReplySettingsStatusResponse, zInitialAnnotationReplySettingsData, zInitialAnnotationReplySettingsResponse, zUpdateAnnotationData, zUpdateAnnotationResponse } from '../../zod/api/apps'
|
||||
import { base } from '../common'
|
||||
|
||||
/**
|
||||
* Get Annotation List
|
||||
*
|
||||
* Retrieves a list of annotations for the application.
|
||||
*/
|
||||
export const getAnnotationListContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/apps/annotations',
|
||||
operationId: 'getAnnotationList',
|
||||
summary: 'Get Annotation List',
|
||||
description: 'Retrieves a list of annotations for the application.',
|
||||
tags: ['Annotations'],
|
||||
}).input(zGetAnnotationListData).output(z.object({ body: zGetAnnotationListResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Create Annotation
|
||||
*
|
||||
* Creates a new annotation.
|
||||
*/
|
||||
export const createAnnotationContract = base.route({
|
||||
method: 'POST',
|
||||
path: '/apps/annotations',
|
||||
operationId: 'createAnnotation',
|
||||
summary: 'Create Annotation',
|
||||
description: 'Creates a new annotation.',
|
||||
tags: ['Annotations'],
|
||||
}).input(zCreateAnnotationData).output(z.object({ body: zCreateAnnotationResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Delete Annotation
|
||||
*
|
||||
* Deletes an annotation.
|
||||
*/
|
||||
export const deleteAnnotationContract = base.route({
|
||||
method: 'DELETE',
|
||||
path: '/apps/annotations/{annotation_id}',
|
||||
operationId: 'deleteAnnotation',
|
||||
summary: 'Delete Annotation',
|
||||
description: 'Deletes an annotation.',
|
||||
tags: ['Annotations'],
|
||||
}).input(zDeleteAnnotationData)
|
||||
|
||||
/**
|
||||
* Update Annotation
|
||||
*
|
||||
* Updates an existing annotation.
|
||||
*/
|
||||
export const updateAnnotationContract = base.route({
|
||||
method: 'PUT',
|
||||
path: '/apps/annotations/{annotation_id}',
|
||||
operationId: 'updateAnnotation',
|
||||
summary: 'Update Annotation',
|
||||
description: 'Updates an existing annotation.',
|
||||
tags: ['Annotations'],
|
||||
}).input(zUpdateAnnotationData).output(z.object({ body: zUpdateAnnotationResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Initial Annotation Reply Settings
|
||||
*
|
||||
* Enable or disable annotation reply settings and configure embedding models. This interface is executed asynchronously.
|
||||
*/
|
||||
export const initialAnnotationReplySettingsContract = base.route({
|
||||
method: 'POST',
|
||||
path: '/apps/annotation-reply/{action}',
|
||||
operationId: 'initialAnnotationReplySettings',
|
||||
summary: 'Initial Annotation Reply Settings',
|
||||
description: 'Enable or disable annotation reply settings and configure embedding models. This interface is executed asynchronously.',
|
||||
tags: ['Annotations'],
|
||||
}).input(zInitialAnnotationReplySettingsData).output(z.object({ body: zInitialAnnotationReplySettingsResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Query Initial Annotation Reply Settings Task Status
|
||||
*
|
||||
* Queries the status of an asynchronously executed annotation reply settings task.
|
||||
*/
|
||||
export const getInitialAnnotationReplySettingsStatusContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/apps/annotation-reply/{action}/status/{job_id}',
|
||||
operationId: 'getInitialAnnotationReplySettingsStatus',
|
||||
summary: 'Query Initial Annotation Reply Settings Task Status',
|
||||
description: 'Queries the status of an asynchronously executed annotation reply settings task.',
|
||||
tags: ['Annotations'],
|
||||
}).input(zGetInitialAnnotationReplySettingsStatusData).output(z.object({ body: zGetInitialAnnotationReplySettingsStatusResponse, status: z.literal(200) }))
|
||||
@@ -0,0 +1,20 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { zAudioToTextData, zAudioToTextResponse } from '../../zod/api/audio-to-text'
|
||||
import { base } from '../common'
|
||||
|
||||
/**
|
||||
* Speech to Text
|
||||
*
|
||||
* Convert audio file to text. Supported formats: mp3, mp4, mpeg, mpga, m4a, wav, webm. File size limit: 15MB.
|
||||
*/
|
||||
export const audioToTextContract = base.route({
|
||||
method: 'POST',
|
||||
path: '/audio-to-text',
|
||||
operationId: 'audioToText',
|
||||
summary: 'Speech to Text',
|
||||
description: 'Convert audio file to text. Supported formats: mp3, mp4, mpeg, mpga, m4a, wav, webm. File size limit: 15MB.',
|
||||
tags: ['TTS'],
|
||||
}).input(zAudioToTextData).output(z.object({ body: zAudioToTextResponse, status: z.literal(200) }))
|
||||
@@ -0,0 +1,34 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { zSendChatMessageData, zSendChatMessageResponse, zStopChatMessageGenerationData, zStopChatMessageGenerationResponse } from '../../zod/api/chat-messages'
|
||||
import { base } from '../common'
|
||||
|
||||
/**
|
||||
* Send Chat Message
|
||||
*
|
||||
* Send a request to the chat application.
|
||||
*/
|
||||
export const sendChatMessageContract = base.route({
|
||||
method: 'POST',
|
||||
path: '/chat-messages',
|
||||
operationId: 'sendChatMessage',
|
||||
summary: 'Send Chat Message',
|
||||
description: 'Send a request to the chat application.',
|
||||
tags: ['Chat'],
|
||||
}).input(zSendChatMessageData).output(z.object({ body: zSendChatMessageResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Stop Chat Message Generation
|
||||
*
|
||||
* Stops a chat message generation task. Only supported in streaming mode.
|
||||
*/
|
||||
export const stopChatMessageGenerationContract = base.route({
|
||||
method: 'POST',
|
||||
path: '/chat-messages/{task_id}/stop',
|
||||
operationId: 'stopChatMessageGeneration',
|
||||
summary: 'Stop Chat Message Generation',
|
||||
description: 'Stops a chat message generation task. Only supported in streaming mode.',
|
||||
tags: ['Chat'],
|
||||
}).input(zStopChatMessageGenerationData).output(z.object({ body: zStopChatMessageGenerationResponse, status: z.literal(200) }))
|
||||
@@ -0,0 +1,62 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { zDeleteConversationData, zGetConversationsListData, zGetConversationsListResponse, zGetConversationVariablesData, zGetConversationVariablesResponse, zRenameConversationData, zRenameConversationResponse } from '../../zod/api/conversations'
|
||||
import { base } from '../common'
|
||||
|
||||
/**
|
||||
* Get Conversations
|
||||
*
|
||||
* Retrieve the conversation list for the current user, defaulting to the most recent 20 entries.
|
||||
*/
|
||||
export const getConversationsListContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/conversations',
|
||||
operationId: 'getConversationsList',
|
||||
summary: 'Get Conversations',
|
||||
description: 'Retrieve the conversation list for the current user, defaulting to the most recent 20 entries.',
|
||||
tags: ['Conversations'],
|
||||
}).input(zGetConversationsListData).output(z.object({ body: zGetConversationsListResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Delete Conversation
|
||||
*
|
||||
* Delete a conversation.
|
||||
*/
|
||||
export const deleteConversationContract = base.route({
|
||||
method: 'DELETE',
|
||||
path: '/conversations/{conversation_id}',
|
||||
operationId: 'deleteConversation',
|
||||
summary: 'Delete Conversation',
|
||||
description: 'Delete a conversation.',
|
||||
tags: ['Conversations'],
|
||||
}).input(zDeleteConversationData)
|
||||
|
||||
/**
|
||||
* Conversation Rename
|
||||
*
|
||||
* Rename the session. The session name is used for display on clients that support multiple sessions.
|
||||
*/
|
||||
export const renameConversationContract = base.route({
|
||||
method: 'POST',
|
||||
path: '/conversations/{conversation_id}/name',
|
||||
operationId: 'renameConversation',
|
||||
summary: 'Conversation Rename',
|
||||
description: 'Rename the session. The session name is used for display on clients that support multiple sessions.',
|
||||
tags: ['Conversations'],
|
||||
}).input(zRenameConversationData).output(z.object({ body: zRenameConversationResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Get Conversation Variables
|
||||
*
|
||||
* Retrieve variables from a specific conversation.
|
||||
*/
|
||||
export const getConversationVariablesContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/conversations/{conversation_id}/variables',
|
||||
operationId: 'getConversationVariables',
|
||||
summary: 'Get Conversation Variables',
|
||||
description: 'Retrieve variables from a specific conversation.',
|
||||
tags: ['Conversations'],
|
||||
}).input(zGetConversationVariablesData).output(z.object({ body: zGetConversationVariablesResponse, status: z.literal(200) }))
|
||||
@@ -0,0 +1,34 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { zPreviewChatFileData, zPreviewChatFileResponse, zUploadChatFileData, zUploadChatFileResponse } from '../../zod/api/files'
|
||||
import { base } from '../common'
|
||||
|
||||
/**
|
||||
* File Upload
|
||||
*
|
||||
* Upload a file (currently only images are supported) for use when sending messages, enabling multimodal understanding of images and text. Supports png, jpg, jpeg, webp, gif formats. Uploaded files are for use by the current end-user only.
|
||||
*/
|
||||
export const uploadChatFileContract = base.route({
|
||||
method: 'POST',
|
||||
path: '/files/upload',
|
||||
operationId: 'uploadChatFile',
|
||||
summary: 'File Upload',
|
||||
description: 'Upload a file (currently only images are supported) for use when sending messages, enabling multimodal understanding of images and text. Supports png, jpg, jpeg, webp, gif formats. Uploaded files are for use by the current end-user only.',
|
||||
tags: ['Files'],
|
||||
}).input(zUploadChatFileData).output(z.object({ body: zUploadChatFileResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* File Preview
|
||||
*
|
||||
* Preview or download uploaded files. This endpoint allows you to access files that have been previously uploaded via the File Upload API. Files can only be accessed if they belong to messages within the requesting application.
|
||||
*/
|
||||
export const previewChatFileContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/files/{file_id}/preview',
|
||||
operationId: 'previewChatFile',
|
||||
summary: 'File Preview',
|
||||
description: 'Preview or download uploaded files. This endpoint allows you to access files that have been previously uploaded via the File Upload API. Files can only be accessed if they belong to messages within the requesting application.',
|
||||
tags: ['Files'],
|
||||
}).input(zPreviewChatFileData).output(z.object({ body: zPreviewChatFileResponse, status: z.literal(200) }))
|
||||
@@ -0,0 +1,20 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { zGetChatAppInfoResponse } from '../../zod/api/info'
|
||||
import { base } from '../common'
|
||||
|
||||
/**
|
||||
* Get Application Basic Information
|
||||
*
|
||||
* Used to get basic information about this application.
|
||||
*/
|
||||
export const getChatAppInfoContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/info',
|
||||
operationId: 'getChatAppInfo',
|
||||
summary: 'Get Application Basic Information',
|
||||
description: 'Used to get basic information about this application.',
|
||||
tags: ['Application'],
|
||||
}).output(z.object({ body: zGetChatAppInfoResponse, status: z.literal(200) }))
|
||||
@@ -0,0 +1,48 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { zGetConversationHistoryData, zGetConversationHistoryResponse, zGetSuggestedQuestionsData, zGetSuggestedQuestionsResponse, zPostChatMessageFeedbackData, zPostChatMessageFeedbackResponse } from '../../zod/api/messages'
|
||||
import { base } from '../common'
|
||||
|
||||
/**
|
||||
* Message Feedback
|
||||
*
|
||||
* End-users can provide feedback messages, facilitating application developers to optimize expected outputs.
|
||||
*/
|
||||
export const postChatMessageFeedbackContract = base.route({
|
||||
method: 'POST',
|
||||
path: '/messages/{message_id}/feedbacks',
|
||||
operationId: 'postChatMessageFeedback',
|
||||
summary: 'Message Feedback',
|
||||
description: 'End-users can provide feedback messages, facilitating application developers to optimize expected outputs.',
|
||||
tags: ['Feedback'],
|
||||
}).input(zPostChatMessageFeedbackData).output(z.object({ body: zPostChatMessageFeedbackResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Next Suggested Questions
|
||||
*
|
||||
* Get next questions suggestions for the current message.
|
||||
*/
|
||||
export const getSuggestedQuestionsContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/messages/{message_id}/suggested',
|
||||
operationId: 'getSuggestedQuestions',
|
||||
summary: 'Next Suggested Questions',
|
||||
description: 'Get next questions suggestions for the current message.',
|
||||
tags: ['Chat'],
|
||||
}).input(zGetSuggestedQuestionsData).output(z.object({ body: zGetSuggestedQuestionsResponse, status: z.literal(200) }))
|
||||
|
||||
/**
|
||||
* Get Conversation History Messages
|
||||
*
|
||||
* Returns historical chat records in a scrolling load format, with the first page returning the latest `{limit}` messages, i.e., in reverse order.
|
||||
*/
|
||||
export const getConversationHistoryContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/messages',
|
||||
operationId: 'getConversationHistory',
|
||||
summary: 'Get Conversation History Messages',
|
||||
description: 'Returns historical chat records in a scrolling load format, with the first page returning the latest `{limit}` messages, i.e., in reverse order.',
|
||||
tags: ['Conversations'],
|
||||
}).input(zGetConversationHistoryData).output(z.object({ body: zGetConversationHistoryResponse, status: z.literal(200) }))
|
||||
@@ -0,0 +1,20 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { zGetChatAppMetaResponse } from '../../zod/api/meta'
|
||||
import { base } from '../common'
|
||||
|
||||
/**
|
||||
* Get Application Meta Information
|
||||
*
|
||||
* Used to get icons of tools in this application.
|
||||
*/
|
||||
export const getChatAppMetaContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/meta',
|
||||
operationId: 'getChatAppMeta',
|
||||
summary: 'Get Application Meta Information',
|
||||
description: 'Used to get icons of tools in this application.',
|
||||
tags: ['Application'],
|
||||
}).output(z.object({ body: zGetChatAppMetaResponse, status: z.literal(200) }))
|
||||
@@ -0,0 +1,20 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { zGetChatAppParametersData, zGetChatAppParametersResponse } from '../../zod/api/parameters'
|
||||
import { base } from '../common'
|
||||
|
||||
/**
|
||||
* Get Application Parameters Information
|
||||
*
|
||||
* Used at the start of entering the page to obtain information such as features, input parameter names, types, and default values.
|
||||
*/
|
||||
export const getChatAppParametersContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/parameters',
|
||||
operationId: 'getChatAppParameters',
|
||||
summary: 'Get Application Parameters Information',
|
||||
description: 'Used at the start of entering the page to obtain information such as features, input parameter names, types, and default values.',
|
||||
tags: ['Application'],
|
||||
}).input(zGetChatAppParametersData).output(z.object({ body: zGetChatAppParametersResponse, status: z.literal(200) }))
|
||||
@@ -0,0 +1,20 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { zGetChatWebAppSettingsResponse } from '../../zod/api/site'
|
||||
import { base } from '../common'
|
||||
|
||||
/**
|
||||
* Get Application WebApp Settings
|
||||
*
|
||||
* Used to get the WebApp settings of the application.
|
||||
*/
|
||||
export const getChatWebAppSettingsContract = base.route({
|
||||
method: 'GET',
|
||||
path: '/site',
|
||||
operationId: 'getChatWebAppSettings',
|
||||
summary: 'Get Application WebApp Settings',
|
||||
description: 'Used to get the WebApp settings of the application.',
|
||||
tags: ['Application'],
|
||||
}).output(z.object({ body: zGetChatWebAppSettingsResponse, status: z.literal(200) }))
|
||||
@@ -0,0 +1,20 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { zTextToAudioChatData, zTextToAudioChatResponse } from '../../zod/api/text-to-audio'
|
||||
import { base } from '../common'
|
||||
|
||||
/**
|
||||
* Text to Audio
|
||||
*
|
||||
* Convert text to speech.
|
||||
*/
|
||||
export const textToAudioChatContract = base.route({
|
||||
method: 'POST',
|
||||
path: '/text-to-audio',
|
||||
operationId: 'textToAudioChat',
|
||||
summary: 'Text to Audio',
|
||||
description: 'Convert text to speech.',
|
||||
tags: ['TTS'],
|
||||
}).input(zTextToAudioChatData).output(z.object({ body: zTextToAudioChatResponse, status: z.literal(200) }))
|
||||
@@ -0,0 +1,5 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { oc } from '@orpc/contract'
|
||||
|
||||
export const base = oc.$route({ inputStructure: 'detailed', outputStructure: 'detailed' })
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user