mirror of
https://github.com/langchain-ai/langserve.git
synced 2026-07-25 13:15:44 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fcfc2ba2f5 | |||
| 7eb71dbd5e | |||
| cb6e6425c9 |
@@ -1,49 +0,0 @@
|
||||
name: test-release
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
working-directory:
|
||||
required: true
|
||||
type: string
|
||||
description: "From which folder this pipeline executes"
|
||||
|
||||
env:
|
||||
POETRY_VERSION: "1.5.1"
|
||||
|
||||
jobs:
|
||||
publish_to_test_pypi:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# This permission is used for trusted publishing:
|
||||
# https://blog.pypi.org/posts/2023-04-20-introducing-trusted-publishers/
|
||||
#
|
||||
# Trusted publishing has to also be configured on PyPI for each package:
|
||||
# https://docs.pypi.org/trusted-publishers/adding-a-publisher/
|
||||
id-token: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python + Poetry ${{ env.POETRY_VERSION }}
|
||||
uses: "./.github/actions/poetry_setup"
|
||||
with:
|
||||
python-version: "3.10"
|
||||
poetry-version: ${{ env.POETRY_VERSION }}
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
cache-key: release
|
||||
|
||||
- name: Build project for distribution
|
||||
run: poetry build
|
||||
- name: Check Version
|
||||
id: check-version
|
||||
run: |
|
||||
echo version=$(poetry version --short) >> $GITHUB_OUTPUT
|
||||
- name: Publish package to TestPyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
verbose: true
|
||||
print-hash: true
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
name: Test Release
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Allows to trigger the workflow manually in GitHub UI
|
||||
|
||||
jobs:
|
||||
release:
|
||||
uses:
|
||||
./.github/workflows/_test_release.yml
|
||||
with:
|
||||
working-directory: .
|
||||
secrets: inherit
|
||||
@@ -1,4 +1,4 @@
|
||||
# LangServe 🦜️🔗
|
||||
# LangServe 🦜️🔗
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -15,7 +15,6 @@ A javascript client is available in [LangChainJS](https://js.langchain.com/docs/
|
||||
- API docs page with JSONSchema and Swagger (insert example link)
|
||||
- Efficient `/invoke`, `/batch` and `/stream` endpoints with support for many concurrent requests on a single server
|
||||
- `/stream_log` endpoint for streaming all (or some) intermediate steps from your chain/agent
|
||||
- Playground page at `/playground` with streaming output and intermediate steps
|
||||
- Built-in (optional) tracing to [LangSmith](https://www.langchain.com/langsmith), just add your API key (see [Instructions](https://docs.smith.langchain.com/)])
|
||||
- All built with battle-tested open-source Python libraries like FastAPI, Pydantic, uvloop and asyncio.
|
||||
- Use the client SDK to call a LangServe server as if it was a Runnable running locally (or call the HTTP API directly)
|
||||
@@ -25,7 +24,7 @@ A javascript client is available in [LangChainJS](https://js.langchain.com/docs/
|
||||
- Client callbacks are not yet supported for events that originate on the server
|
||||
- Does not work with [pydantic v2 yet](https://github.com/tiangolo/fastapi/issues/10360)
|
||||
|
||||
## LangChain CLI 🛠️
|
||||
## LangChain CLI 🛠️
|
||||
|
||||
Use the `LangChain` CLI to bootstrap a `LangServe` project quickly.
|
||||
|
||||
@@ -42,6 +41,7 @@ And follow the instructions...
|
||||
|
||||
For more examples, see the [examples](./examples) directory.
|
||||
|
||||
|
||||
### Server
|
||||
|
||||
Here's a server that deploys an OpenAI chat model, an Anthropic chat model, and a chain that uses
|
||||
@@ -95,14 +95,6 @@ If you've deployed the server above, you can view the generated OpenAPI docs usi
|
||||
curl localhost:8000/docs
|
||||
```
|
||||
|
||||
make sure to **add** the `/docs` suffix.
|
||||
|
||||
Below will return a 404 until you define a `@app.get("/")`
|
||||
|
||||
```sh
|
||||
localhost:8000
|
||||
```
|
||||
|
||||
### Client
|
||||
|
||||
Python SDK
|
||||
@@ -124,18 +116,18 @@ joke_chain.invoke({"topic": "parrots"})
|
||||
await joke_chain.ainvoke({"topic": "parrots"})
|
||||
|
||||
prompt = [
|
||||
SystemMessage(content='Act like either a cat or a parrot.'),
|
||||
SystemMessage(content='Act like either a cat or a parrot.'),
|
||||
HumanMessage(content='Hello!')
|
||||
]
|
||||
|
||||
# Supports astream
|
||||
async for msg in anthropic.astream(prompt):
|
||||
print(msg, end="", flush=True)
|
||||
|
||||
|
||||
prompt = ChatPromptTemplate.from_messages(
|
||||
[("system", "Tell me a long story about {topic}")]
|
||||
)
|
||||
|
||||
|
||||
# Can define custom chains
|
||||
chain = prompt | RunnableMap({
|
||||
"openai": openai,
|
||||
@@ -150,11 +142,9 @@ In TypeScript (requires LangChain.js version 0.0.166 or later):
|
||||
```typescript
|
||||
import { RemoteRunnable } from "langchain/runnables/remote";
|
||||
|
||||
const chain = new RemoteRunnable({
|
||||
url: `http://localhost:8000/chain/invoke/`,
|
||||
});
|
||||
const chain = new RemoteRunnable({ url: `http://localhost:8000/chain/invoke/` });
|
||||
const result = await chain.invoke({
|
||||
topic: "cats",
|
||||
"topic": "cats",
|
||||
});
|
||||
```
|
||||
|
||||
@@ -181,7 +171,8 @@ curl --location --request POST 'http://localhost:8000/chain/invoke/' \
|
||||
}'
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
## Endpoints
|
||||
|
||||
The following code:
|
||||
|
||||
@@ -204,10 +195,6 @@ adds of these endpoints to the server:
|
||||
- `GET /my_runnable/output_schema` - json schema for output of the runnable
|
||||
- `GET /my_runnable/config_schema` - json schema for config of the runnable
|
||||
|
||||
## Playground
|
||||
|
||||
You can find a playground page for your runnable at `/my_runnable/playground`. This exposes a simple UI to [configure](https://python.langchain.com/docs/expression_language/how_to/configure) and invoke your runnable with streaming output and intermediate steps.
|
||||
|
||||
## Installation
|
||||
|
||||
For both client and server:
|
||||
@@ -225,9 +212,10 @@ However, some of the input schemas for legacy chains may be incomplete/incorrect
|
||||
This can be fixed by updating the `input_schema` property of those chains in LangChain.
|
||||
If you encounter any errors, please open an issue on THIS repo, and we will work to address it.
|
||||
|
||||
|
||||
## Handling Authentication
|
||||
|
||||
If you need to add authentication to your server,
|
||||
If you need to add authentication to your server,
|
||||
please reference FastAPI's [security documentation](https://fastapi.tiangolo.com/tutorial/security/)
|
||||
and [middleware documentation](https://fastapi.tiangolo.com/tutorial/middleware/).
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env python
|
||||
"""Example LangChain server exposes a chain composed of a prompt and an LLM."""
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from langchain.chat_models import ChatOpenAI
|
||||
@@ -8,7 +10,7 @@ from langchain.prompts import PromptTemplate
|
||||
# from typing_extensions import TypedDict
|
||||
from langchain.pydantic_v1 import BaseModel
|
||||
from langchain.schema.output_parser import StrOutputParser
|
||||
from langchain.schema.runnable import ConfigurableField
|
||||
from langchain.schema.runnable import ConfigurableField, RunnablePassthrough
|
||||
|
||||
from langserve import add_routes
|
||||
|
||||
@@ -19,7 +21,7 @@ model = ChatOpenAI(temperature=0.5).configurable_alternatives(
|
||||
default_key="medium_temp",
|
||||
)
|
||||
prompt = PromptTemplate.from_template(
|
||||
"tell me a joke about {topic}."
|
||||
"tell me a joke about {topic}.\nChat history: {chat_history}"
|
||||
).configurable_fields(
|
||||
template=ConfigurableField(
|
||||
id="prompt",
|
||||
@@ -27,7 +29,12 @@ prompt = PromptTemplate.from_template(
|
||||
description="The prompt to use. Must contain {topic}",
|
||||
)
|
||||
)
|
||||
chain = prompt | model | StrOutputParser()
|
||||
chain = (
|
||||
RunnablePassthrough.assign(chat_history=(lambda x: "\n".join(x)))
|
||||
| prompt
|
||||
| model
|
||||
| StrOutputParser()
|
||||
)
|
||||
|
||||
app = FastAPI(
|
||||
title="LangChain Server",
|
||||
@@ -53,6 +60,11 @@ class ChainInput(BaseModel):
|
||||
"""The input to the chain."""
|
||||
|
||||
topic: str
|
||||
"""The topic of the joke."""
|
||||
chat_history: List[str]
|
||||
chat_history_tuples: List[Tuple[str, str]]
|
||||
chat_history_object_list: List[Dict[str, str]]
|
||||
tester: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
add_routes(app, chain, input_type=ChainInput, config_keys=["configurable"])
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from langchain.schema.runnable import RunnablePassthrough"
|
||||
"from langchain.schema.runnable import PutLocalVar, GetLocalVar"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -266,8 +266,13 @@
|
||||
")\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"chain = {\"joke\": comedian_chain} | RunnablePassthrough.assign(\n",
|
||||
" classification=joke_classifier_chain\n",
|
||||
"chain = (\n",
|
||||
" comedian_chain\n",
|
||||
" | PutLocalVar(\"joke\")\n",
|
||||
" | {\"joke\": GetLocalVar(\"joke\")}\n",
|
||||
" | joke_classifier_chain\n",
|
||||
" | PutLocalVar(\"classification\")\n",
|
||||
" | {\"joke\": GetLocalVar(\"joke\"), \"classification\": GetLocalVar(\"classification\")}\n",
|
||||
")"
|
||||
]
|
||||
},
|
||||
|
||||
+5
-62
@@ -1,11 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import weakref
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from functools import lru_cache
|
||||
from typing import (
|
||||
Any,
|
||||
AsyncIterator,
|
||||
@@ -33,8 +30,6 @@ from langchain.schema.runnable.utils import Input, Output
|
||||
|
||||
from langserve.serialization import simple_dumpd, simple_loads
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _without_callbacks(config: Optional[RunnableConfig]) -> RunnableConfig:
|
||||
"""Evict callbacks from the config since those are definitely not supported."""
|
||||
@@ -42,12 +37,6 @@ def _without_callbacks(config: Optional[RunnableConfig]) -> RunnableConfig:
|
||||
return {k: v for k, v in _config.items() if k != "callbacks"}
|
||||
|
||||
|
||||
@lru_cache(maxsize=1_000) # Will accommodate up to 100 different error messages
|
||||
def _log_error_message_once(error_message: str) -> None:
|
||||
"""Log an error message once."""
|
||||
logger.error(error_message)
|
||||
|
||||
|
||||
def _raise_for_status(response: httpx.Response) -> None:
|
||||
"""Re-raise with a more informative message.
|
||||
|
||||
@@ -105,26 +94,6 @@ def _close_clients(sync_client: httpx.Client, async_client: httpx.AsyncClient) -
|
||||
asyncio.run(async_client.aclose())
|
||||
|
||||
|
||||
def _raise_exception_from_data(data: str, request: httpx.Request) -> None:
|
||||
"""Raise an httpx exception from the given error event data."""
|
||||
try:
|
||||
decoded_data = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
raise httpx.HTTPStatusError(
|
||||
message="invalid json in error event sent from server",
|
||||
request=request,
|
||||
response=httpx.Response(status_code=500, text=data),
|
||||
)
|
||||
raise httpx.HTTPStatusError(
|
||||
message=decoded_data["message"],
|
||||
request=request,
|
||||
response=httpx.Response(
|
||||
status_code=decoded_data["status_code"],
|
||||
text=decoded_data["message"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class RemoteRunnable(Runnable[Input, Output]):
|
||||
"""A RemoteRunnable is a runnable that is executed on a remote server.
|
||||
|
||||
@@ -365,19 +334,10 @@ class RemoteRunnable(Runnable[Input, Output]):
|
||||
final_output += chunk
|
||||
else:
|
||||
final_output = chunk
|
||||
elif sse.event == "error":
|
||||
# This can only be a server side error
|
||||
_raise_exception_from_data(
|
||||
sse.data, httpx.Request(method="POST", url=endpoint)
|
||||
)
|
||||
elif sse.event == "end":
|
||||
break
|
||||
else:
|
||||
logger.error(
|
||||
f"Encountered an unsupported event type: `{sse.event}`. "
|
||||
f"Try upgrading the remote client to the latest version."
|
||||
f"Ignoring events of type `{sse.event}`."
|
||||
)
|
||||
raise NotImplementedError(f"Unknown event {sse.event}")
|
||||
except BaseException as e:
|
||||
run_manager.on_chain_error(e)
|
||||
raise
|
||||
@@ -425,20 +385,11 @@ class RemoteRunnable(Runnable[Input, Output]):
|
||||
final_output += chunk
|
||||
else:
|
||||
final_output = chunk
|
||||
|
||||
elif sse.event == "error":
|
||||
# This can only be a server side error
|
||||
_raise_exception_from_data(
|
||||
sse.data, httpx.Request(method="POST", url=endpoint)
|
||||
)
|
||||
elif sse.event == "end":
|
||||
break
|
||||
|
||||
else:
|
||||
logger.error(
|
||||
f"Encountered an unsupported event type: `{sse.event}`. "
|
||||
f"Try upgrading the remote client to the latest version."
|
||||
f"Ignoring events of type `{sse.event}`."
|
||||
)
|
||||
raise NotImplementedError(f"Unknown event {sse.event}")
|
||||
except BaseException as e:
|
||||
await run_manager.on_chain_error(e)
|
||||
raise
|
||||
@@ -509,23 +460,15 @@ class RemoteRunnable(Runnable[Input, Output]):
|
||||
chunk = RunLogPatch(*data["ops"])
|
||||
|
||||
yield chunk
|
||||
|
||||
if final_output:
|
||||
final_output += chunk
|
||||
else:
|
||||
final_output = chunk
|
||||
elif sse.event == "error":
|
||||
# This can only be a server side error
|
||||
_raise_exception_from_data(
|
||||
sse.data, httpx.Request(method="POST", url=endpoint)
|
||||
)
|
||||
elif sse.event == "end":
|
||||
break
|
||||
else:
|
||||
_log_error_message_once(
|
||||
f"Encountered an unsupported event type: `{sse.event}`. "
|
||||
f"Try upgrading the remote client to the latest version."
|
||||
f"Ignoring events of type `{sse.event}`."
|
||||
)
|
||||
raise NotImplementedError(f"Unknown event {sse.event}")
|
||||
except BaseException as e:
|
||||
await run_manager.on_chain_error(e)
|
||||
raise
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import importlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Generator, TypedDict, Union
|
||||
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from tomli import load
|
||||
|
||||
from langserve.server import add_routes
|
||||
|
||||
|
||||
class LangServeExport(TypedDict):
|
||||
"""
|
||||
Fields from pyproject.toml that are relevant to LangServe
|
||||
|
||||
Attributes:
|
||||
module: The module to import from, tool.langserve.export_module
|
||||
attr: The attribute to import from the module, tool.langserve.export_attr
|
||||
package_name: The name of the package, tool.poetry.name
|
||||
"""
|
||||
|
||||
module: str
|
||||
attr: str
|
||||
package_name: str
|
||||
|
||||
|
||||
def get_langserve_export(filepath: Path) -> LangServeExport:
|
||||
with open(filepath, "rb") as f:
|
||||
data = load(f)
|
||||
try:
|
||||
module = data["tool"]["langserve"]["export_module"]
|
||||
attr = data["tool"]["langserve"]["export_attr"]
|
||||
package_name = data["tool"]["poetry"]["name"]
|
||||
except KeyError as e:
|
||||
raise KeyError("Invalid LangServe PyProject.toml") from e
|
||||
return LangServeExport(module=module, attr=attr, package_name=package_name)
|
||||
|
||||
|
||||
EXCLUDE_PATHS = set(["__pycache__", ".venv", ".git", ".github"])
|
||||
|
||||
|
||||
def _include_path(path: Path) -> bool:
|
||||
"""
|
||||
Skip paths that are in EXCLUDE_PATHS or start with an underscore.
|
||||
"""
|
||||
for part in path.parts:
|
||||
if part in EXCLUDE_PATHS:
|
||||
return False
|
||||
if part.startswith("_"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def list_packages(path: str = "packages") -> Generator[Path, None, None]:
|
||||
"""
|
||||
Yields Path objects for each folder that contains a pyproject.toml file within a
|
||||
path. Use this to find packages to add to the server.
|
||||
|
||||
See `add_package_routes` below for an example of how to use this.
|
||||
"""
|
||||
# traverse path for routes to host (any directory holding a pyproject.toml file)
|
||||
package_root = Path(path)
|
||||
for pyproject_path in package_root.glob("**/pyproject.toml"):
|
||||
if not _include_path(pyproject_path):
|
||||
continue
|
||||
yield pyproject_path.parent
|
||||
|
||||
|
||||
def add_package_route(
|
||||
app: Union[FastAPI, APIRouter], package_path: Path, mount_path: str
|
||||
) -> None:
|
||||
try:
|
||||
pyproject_path = package_path / "pyproject.toml"
|
||||
|
||||
# get langserve export
|
||||
package = get_langserve_export(pyproject_path)
|
||||
package_name = package["package_name"]
|
||||
# import module
|
||||
mod = importlib.import_module(package["module"])
|
||||
except KeyError:
|
||||
logging.warning(
|
||||
f"Skipping {package_path} because it is not a valid LangServe "
|
||||
"package (see pyproject.toml)"
|
||||
)
|
||||
return
|
||||
except ImportError as e:
|
||||
logging.warning(f"Error: {e}")
|
||||
logging.warning(f"Try fixing with `poetry add --editable {package_path}`")
|
||||
logging.warning(
|
||||
"To remove packages, use `poe` instead of `poetry`: "
|
||||
f"`poe remove {package_name}`"
|
||||
)
|
||||
return
|
||||
# get attr
|
||||
chain = getattr(mod, package["attr"])
|
||||
add_routes(app, chain, path=mount_path)
|
||||
|
||||
|
||||
def add_package_routes(app: Union[FastAPI, APIRouter], path: str = "packages") -> None:
|
||||
# traverse path for routes to host (any directory holding a pyproject.toml file)
|
||||
for package_path in list_packages(path):
|
||||
mount_path_relative = package_path.relative_to(Path(path))
|
||||
mount_path = f"/{mount_path_relative}"
|
||||
add_package_route(app, package_path, mount_path)
|
||||
+216
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-247
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" href="/____LANGSERVE_BASE_URL/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Playground</title>
|
||||
<script type="module" crossorigin src="/____LANGSERVE_BASE_URL/assets/index-9fe7f71c.js"></script>
|
||||
<link rel="stylesheet" href="/____LANGSERVE_BASE_URL/assets/index-5008c8a8.css">
|
||||
<script type="module" crossorigin src="/____LANGSERVE_BASE_URL/assets/index-38194dca.js"></script>
|
||||
<link rel="stylesheet" href="/____LANGSERVE_BASE_URL/assets/index-d96a8751.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"@mui/icons-material": "^5.14.11",
|
||||
"@mui/material": "^5.14.11",
|
||||
"@mui/x-date-pickers": "^6.16.0",
|
||||
"clsx": "^2.0.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"json-schema-defaults": "^0.4.0",
|
||||
@@ -28,8 +27,6 @@
|
||||
"lz-string": "^1.5.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"use-debounce": "^9.0.4",
|
||||
"vaul": "^0.7.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -55,8 +55,7 @@
|
||||
@apply focus-within:border-ls-blue focus-within:outline focus-within:outline-4 focus-within:outline-ls-blue/20;
|
||||
}
|
||||
|
||||
.control > label,
|
||||
.control h6 {
|
||||
.control > label, .control h6 {
|
||||
@apply text-xs uppercase font-semibold text-ls-gray-100;
|
||||
}
|
||||
|
||||
@@ -68,8 +67,7 @@
|
||||
@apply -ml-1;
|
||||
}
|
||||
|
||||
.control > .input-description,
|
||||
.control > .validation {
|
||||
.control .input-description {
|
||||
@apply absolute right-3 top-3 text-xs;
|
||||
}
|
||||
|
||||
@@ -84,7 +82,3 @@
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vertical-layout {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "./App.css";
|
||||
import { Drawer } from "vaul";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import defaults from "json-schema-defaults";
|
||||
import { JsonForms } from "@jsonforms/react";
|
||||
import {
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
MaterialAllOfRenderer,
|
||||
materialAnyOfControlTester,
|
||||
MaterialAnyOfRenderer,
|
||||
materialObjectControlTester,
|
||||
MaterialObjectRenderer,
|
||||
materialOneOfControlTester,
|
||||
MaterialOneOfRenderer,
|
||||
@@ -17,8 +19,12 @@ import utc from "dayjs/plugin/utc";
|
||||
import relativeDate from "dayjs/plugin/relativeTime";
|
||||
import SendIcon from "./assets/SendIcon.svg?react";
|
||||
import ShareIcon from "./assets/ShareIcon.svg?react";
|
||||
import ChevronRight from "./assets/ChevronRight.svg?react";
|
||||
import { compressToEncodedURIComponent } from "lz-string";
|
||||
import CodeIcon from "./assets/CodeIcon.svg?react";
|
||||
import PadlockIcon from "./assets/PadlockIcon.svg?react";
|
||||
import {
|
||||
compressToEncodedURIComponent,
|
||||
decompressFromEncodedURIComponent,
|
||||
} from "lz-string";
|
||||
|
||||
import {
|
||||
BooleanCell,
|
||||
@@ -40,69 +46,45 @@ import {
|
||||
textCellTester,
|
||||
timeCellTester,
|
||||
vanillaRenderers,
|
||||
InputControl,
|
||||
} from "@jsonforms/vanilla-renderers";
|
||||
|
||||
import { useSchemas } from "./useSchemas";
|
||||
import { RunState, useStreamLog } from "./useStreamLog";
|
||||
import {
|
||||
JsonFormsCore,
|
||||
RankedTester,
|
||||
rankWith,
|
||||
and,
|
||||
uiTypeIs,
|
||||
schemaMatches,
|
||||
schemaTypeIs,
|
||||
} from "@jsonforms/core";
|
||||
import CustomArrayControlRenderer, {
|
||||
materialArrayControlTester,
|
||||
} from "./components/CustomArrayControlRenderer";
|
||||
import { JsonFormsCore, RankedTester, rankWith } from "@jsonforms/core";
|
||||
import CustomArrayControlRenderer, { materialArrayControlTester } from "./components/CustomArrayControlRenderer";
|
||||
import CustomTextAreaCell from "./components/CustomTextAreaCell";
|
||||
import JsonTextAreaCell from "./components/JsonTextAreaCell";
|
||||
import { cn } from "./utils/cn";
|
||||
import { getStateFromUrl, ShareDialog } from "./components/ShareDialog";
|
||||
|
||||
dayjs.extend(relativeDate);
|
||||
dayjs.extend(utc);
|
||||
|
||||
const URL_LENGTH_LIMIT = 2000;
|
||||
|
||||
function str(o: unknown): React.ReactNode {
|
||||
return typeof o === "object"
|
||||
? JSON.stringify(o, null, 2)
|
||||
: (o as React.ReactNode);
|
||||
}
|
||||
|
||||
const isObjectWithPropertiesControl = rankWith(
|
||||
2,
|
||||
and(
|
||||
uiTypeIs("Control"),
|
||||
schemaTypeIs("object"),
|
||||
schemaMatches((schema) =>
|
||||
Object.prototype.hasOwnProperty.call(schema, "properties")
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const isObject = rankWith(1, and(uiTypeIs("Control"), schemaTypeIs("object")));
|
||||
const isElse = rankWith(1, and(uiTypeIs("Control")));
|
||||
|
||||
const renderers = [
|
||||
...vanillaRenderers,
|
||||
|
||||
// use material renderers to handle objects and json schema references
|
||||
// they should yield the rendering to simpler cells
|
||||
{ tester: isObjectWithPropertiesControl, renderer: MaterialObjectRenderer },
|
||||
{ tester: materialObjectControlTester, renderer: MaterialObjectRenderer },
|
||||
{ tester: materialAllOfControlTester, renderer: MaterialAllOfRenderer },
|
||||
{ tester: materialAnyOfControlTester, renderer: MaterialAnyOfRenderer },
|
||||
{ tester: materialOneOfControlTester, renderer: MaterialOneOfRenderer },
|
||||
|
||||
// custom renderers
|
||||
{ tester: materialArrayControlTester, renderer: CustomArrayControlRenderer },
|
||||
{ tester: isObject, renderer: InputControl },
|
||||
{ tester: materialArrayControlTester, renderer: CustomArrayControlRenderer }
|
||||
];
|
||||
|
||||
const nestedArrayControlTester: RankedTester = rankWith(1, (_, jsonSchema) => {
|
||||
return jsonSchema.type === "array";
|
||||
});
|
||||
export const nestedArrayControlTester: RankedTester = rankWith(
|
||||
1,
|
||||
(_, jsonSchema) => {
|
||||
return jsonSchema.type === "array";
|
||||
}
|
||||
);
|
||||
|
||||
const cells = [
|
||||
{ tester: booleanCellTester, cell: BooleanCell },
|
||||
@@ -116,7 +98,6 @@ const cells = [
|
||||
{ tester: textCellTester, cell: CustomTextAreaCell },
|
||||
{ tester: timeCellTester, cell: TimeCell },
|
||||
{ tester: nestedArrayControlTester, cell: CustomArrayControlRenderer },
|
||||
{ tester: isElse, cell: JsonTextAreaCell },
|
||||
];
|
||||
|
||||
function IntermediateSteps(props: { latest: RunState }) {
|
||||
@@ -124,13 +105,10 @@ function IntermediateSteps(props: { latest: RunState }) {
|
||||
return (
|
||||
<div className="flex flex-col border border-divider-700 rounded-2xl bg-background">
|
||||
<button
|
||||
className="font-medium text-left p-4 flex items-center justify-between"
|
||||
className="font-medium text-left p-4"
|
||||
onClick={() => setExpanded((open) => !open)}
|
||||
>
|
||||
<span>Intermediate steps</span>
|
||||
<ChevronRight
|
||||
className={cn("transition-all", expanded && "rotate-90")}
|
||||
/>
|
||||
Intermediate steps
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="flex flex-col gap-5 p-4 pt-0 divide-solid divide-y divide-divider-700 rounded-b-xl">
|
||||
@@ -154,6 +132,158 @@ function IntermediateSteps(props: { latest: RunState }) {
|
||||
);
|
||||
}
|
||||
|
||||
function getStateFromUrl(path: string) {
|
||||
let configFromUrl = null;
|
||||
let basePath = path;
|
||||
if (basePath.endsWith("/")) {
|
||||
basePath = basePath.slice(0, -1);
|
||||
}
|
||||
|
||||
if (basePath.endsWith("/playground")) {
|
||||
basePath = basePath.slice(0, -"/playground".length);
|
||||
}
|
||||
|
||||
// check if we can omit the last segment
|
||||
const [configHash, c, ...rest] = basePath.split("/").reverse();
|
||||
if (c === "c") {
|
||||
basePath = rest.reverse().join("/");
|
||||
try {
|
||||
configFromUrl = JSON.parse(decompressFromEncodedURIComponent(configHash));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
return { basePath, configFromUrl };
|
||||
}
|
||||
|
||||
function CopyButton(props: { value: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const cbRef = useRef<number | null>(null);
|
||||
|
||||
function toggleCopied() {
|
||||
setCopied(true);
|
||||
|
||||
if (cbRef.current != null) window.clearTimeout(cbRef.current);
|
||||
cbRef.current = window.setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cbRef.current != null) {
|
||||
window.clearTimeout(cbRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
className="px-3 py-1"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(props.value).then(toggleCopied);
|
||||
}}
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ShareDialog(props: { config: unknown; children: ReactNode }) {
|
||||
const hash = useMemo(() => {
|
||||
return compressToEncodedURIComponent(JSON.stringify(props.config));
|
||||
}, [props.config]);
|
||||
|
||||
const state = getStateFromUrl(window.location.href);
|
||||
|
||||
// get base URL
|
||||
const targetUrl = `${state.basePath}/c/${hash}`;
|
||||
|
||||
// .../c/[hash]/playground
|
||||
const playgroundUrl = `${targetUrl}/playground`;
|
||||
|
||||
// cURL, JS: .../c/[hash]/invoke
|
||||
// Python: .../c/[hash]
|
||||
const invokeUrl = `${targetUrl}/invoke`;
|
||||
|
||||
const pythonSnippet = `
|
||||
from langserve import RemoteRunnable
|
||||
|
||||
chain = RemoteRunnable("${targetUrl}")
|
||||
chain.invoke({ ... })
|
||||
`;
|
||||
|
||||
const typescriptSnippet = `
|
||||
import { RemoteRunnable } from "langchain/runnables/remote";
|
||||
|
||||
const chain = new RemoteRunnable({ url: \`${invokeUrl}\` });
|
||||
const result = await chain.invoke({ ... });
|
||||
`;
|
||||
|
||||
return (
|
||||
<Drawer.Root>
|
||||
<Drawer.Trigger asChild>{props.children}</Drawer.Trigger>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
|
||||
<Drawer.Content className="flex justify-center items-center mt-24 fixed bottom-0 left-0 right-0 text-ls-black !pointer-events-none after:!bg-background">
|
||||
<div className="p-4 bg-background max-w-[calc(800px-2rem)] rounded-t-2xl border border-divider-500 border-b-background pointer-events-auto">
|
||||
<h3 className="text-xl font-medium">Share</h3>
|
||||
|
||||
<hr className="border-divider-500 my-4 -mx-4" />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{playgroundUrl.length < URL_LENGTH_LIMIT && (
|
||||
<div className="flex flex-col gap-2 p-3 rounded-2xl dark:bg-[#2C2C2E] bg-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 flex items-center justify-center text-center text-sm bg-background rounded-xl">
|
||||
🦜
|
||||
</div>
|
||||
<span className="font-semibold">Playground</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[auto,1fr,auto] dark:bg-[#111111] bg-white rounded-xl text-sm items-center">
|
||||
<PadlockIcon className="mx-3" />
|
||||
<div className="overflow-auto whitespace-nowrap py-3 no-scrollbar text-ls-gray-100">
|
||||
{playgroundUrl.split("://")[1]}
|
||||
PadlockIcon
|
||||
</div>
|
||||
<CopyButton value={playgroundUrl} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 p-3 rounded-2xl dark:bg-[#2C2C2E] bg-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 flex items-center justify-center text-center text-sm bg-background rounded-xl">
|
||||
<CodeIcon />
|
||||
</div>
|
||||
<span className="font-semibold">Get the code</span>
|
||||
</div>
|
||||
|
||||
{targetUrl.length < URL_LENGTH_LIMIT && (
|
||||
<div className="grid grid-cols-[1fr,auto] dark:bg-[#111111] bg-white rounded-xl text-sm items-center">
|
||||
<div className="overflow-auto whitespace-nowrap px-3 py-3 no-scrollbar text-ls-gray-100">
|
||||
Python SDK
|
||||
</div>
|
||||
<CopyButton value={pythonSnippet.trim()} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invokeUrl.length < URL_LENGTH_LIMIT && (
|
||||
<div className="grid grid-cols-[1fr,auto] dark:bg-[#111111] bg-white rounded-xl text-sm items-center">
|
||||
<div className="overflow-auto whitespace-nowrap px-3 py-3 no-scrollbar text-ls-gray-100">
|
||||
TypeScript SDK
|
||||
</div>
|
||||
|
||||
<CopyButton value={typescriptSnippet.trim()} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [isIframe] = useState(() => window.self !== window.top);
|
||||
|
||||
@@ -164,14 +294,14 @@ function App() {
|
||||
|
||||
// store form state
|
||||
const [configData, setConfigData] = useState<
|
||||
Pick<JsonFormsCore, "data" | "errors"> & { defaults: boolean }
|
||||
>({ data: {}, errors: [], defaults: true });
|
||||
Pick<JsonFormsCore, "data" | "errors">
|
||||
>({ data: {}, errors: [] });
|
||||
|
||||
const [inputData, setInputData] = useState<
|
||||
Pick<JsonFormsCore, "data" | "errors">
|
||||
>({ data: null, errors: [] });
|
||||
>({ data: {}, errors: [] });
|
||||
// fetch input and config schemas from the server
|
||||
const schemas = useSchemas(configData);
|
||||
const schemas = useSchemas();
|
||||
// apply defaults defined in each schema
|
||||
useEffect(() => {
|
||||
if (schemas.config) {
|
||||
@@ -182,9 +312,8 @@ function App() {
|
||||
initConfigData.current ??
|
||||
defaults(schemas.config),
|
||||
errors: [],
|
||||
defaults: true,
|
||||
});
|
||||
setInputData({ data: null, errors: [] });
|
||||
setInputData({ data: {}, errors: [] });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [schemas.config]);
|
||||
@@ -205,11 +334,7 @@ function App() {
|
||||
const value: { config: JsonFormsCore["data"] } = message.value;
|
||||
if (Object.keys(value.config).length > 0) {
|
||||
initConfigData.current = value.config;
|
||||
setConfigData({
|
||||
data: value.config,
|
||||
errors: [],
|
||||
defaults: false,
|
||||
});
|
||||
setConfigData({ data: value.config, errors: [] });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -237,28 +362,23 @@ function App() {
|
||||
renderers={renderers}
|
||||
cells={cells}
|
||||
onChange={({ data, errors }) =>
|
||||
data
|
||||
? setConfigData({ data, errors, defaults: false })
|
||||
: undefined
|
||||
data ? setConfigData({ data, errors }) : undefined
|
||||
}
|
||||
/>
|
||||
{!!configData.errors?.length && configData.data && (
|
||||
<div className="bg-background rounded-xl">
|
||||
<div className="bg-red-500/10 text-red-700 dark:text-red-300 rounded-xl p-3">
|
||||
<strong className="font-bold">Validation Errors</strong>
|
||||
<ul className="list-disc pl-5">
|
||||
{configData.errors?.map((e, i) => (
|
||||
<li key={i}>{e.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{!!configData.errors?.length && (
|
||||
<>
|
||||
<h3>Validation Errors</h3>
|
||||
|
||||
{configData.errors?.map((e, i) => (
|
||||
<p key={i}>{e.message}</p>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isIframe && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-xl font-semibold">Try it</h2>
|
||||
<h2 className="text-xl font-semibold">Customize</h2>
|
||||
|
||||
<div className="p-4 border border-divider-700 flex flex-col gap-3 rounded-2xl bg-background">
|
||||
<h3 className="font-medium">Inputs</h3>
|
||||
@@ -270,15 +390,13 @@ function App() {
|
||||
cells={cells}
|
||||
onChange={({ data, errors }) => setInputData({ data, errors })}
|
||||
/>
|
||||
{!!inputData.errors?.length && inputData.data && (
|
||||
<div className="bg-red-500/10 text-red-700 dark:text-red-300 rounded-xl p-3">
|
||||
<strong className="font-bold">Validation Errors</strong>
|
||||
<ul className="list-disc pl-5">
|
||||
{inputData.errors?.map((e, i) => (
|
||||
<li key={i}>{e.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{!!inputData.errors?.length && (
|
||||
<>
|
||||
<h3>Validation Errors</h3>
|
||||
{inputData.errors?.map((e, i) => (
|
||||
<p key={i}>{e.message}</p>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -294,6 +412,10 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center text-sm">
|
||||
<strong>🦜 LangServe</strong>. An Open Source project by LangChain
|
||||
</div>
|
||||
|
||||
<div className="flex-grow md:hidden" />
|
||||
|
||||
<div className="gap-4 grid grid-cols-2 sticky -mx-4 px-4 py-4 bottom-0 bg-background md:static md:bg-transparent">
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M12 5.45455C8.38505 5.45455 5.45455 8.38505 5.45455 12C5.45455 15.615 8.38505 18.5455 12 18.5455C15.615 18.5455 18.5455 15.615 18.5455 12C18.5455 8.38505 15.615 5.45455 12 5.45455ZM4 12C4 7.58172 7.58172 4 12 4C16.4183 4 20 7.58172 20 12C20 16.4183 16.4183 20 12 20C7.58172 20 4 16.4183 4 12ZM15.787 9.30392C16.071 9.58794 16.071 10.0484 15.787 10.3324L11.4233 14.6961C11.1393 14.9801 10.6788 14.9801 10.3948 14.6961L8.21301 12.5143C7.929 12.2303 7.929 11.7697 8.21301 11.4857C8.49703 11.2017 8.95751 11.2017 9.24153 11.4857L10.9091 13.1533L14.7585 9.30392C15.0425 9.01991 15.503 9.01991 15.787 9.30392Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 792 B |
@@ -1,6 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M8.29289 5.29289C8.68342 4.90237 9.31658 4.90237 9.70711 5.29289L15.7071 11.2929C16.0976 11.6834 16.0976 12.3166 15.7071 12.7071L9.70711 18.7071C9.31658 19.0976 8.68342 19.0976 8.29289 18.7071C7.90237 18.3166 7.90237 17.6834 8.29289 17.2929L13.5858 12L8.29289 6.70711C7.90237 6.31658 7.90237 5.68342 8.29289 5.29289Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 505 B |
@@ -1,6 +0,0 @@
|
||||
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M4.5 19.7783C4.5 21.5132 5.35498 22.3848 7.07324 22.3848H14.876C16.5942 22.3848 17.4492 21.5049 17.4492 19.7783V18.2427H18.9019C20.6118 18.2427 21.4751 17.3628 21.4751 15.6362V8.896C21.4751 7.875 21.2676 7.22754 20.645 6.58838L16.4531 2.33008C15.8638 1.72412 15.1665 1.5 14.2783 1.5H11.0991C9.38916 1.5 8.52588 2.37988 8.52588 4.10645V5.64209H7.07324C5.36328 5.64209 4.5 6.51367 4.5 8.24854V19.7783ZM16.6606 11.0874L12.0869 6.43066C11.4561 5.7832 10.9331 5.64209 10.0034 5.64209H9.8623V4.13135C9.8623 3.30957 10.3022 2.83643 11.1655 2.83643H14.8345V7.09473C14.8345 8.05762 15.2993 8.51416 16.2539 8.51416H20.1387V15.6113C20.1387 16.4414 19.6904 16.9062 18.8271 16.9062H17.4492V13.2954C17.4492 12.2329 17.3247 11.7681 16.6606 11.0874ZM16.0381 6.89551V3.49219L19.79 7.31055H16.4448C16.1543 7.31055 16.0381 7.18604 16.0381 6.89551ZM5.83643 19.7534V8.26514C5.83643 7.45166 6.27637 6.97852 7.13965 6.97852H9.8623V11.793C9.8623 12.8389 10.3936 13.3618 11.4229 13.3618H16.1128V19.7534C16.1128 20.5835 15.6646 21.0483 14.8096 21.0483H7.13135C6.27637 21.0483 5.83643 20.5835 5.83643 19.7534ZM11.5806 12.1084C11.2485 12.1084 11.1157 11.9756 11.1157 11.6436V7.28564L15.8555 12.1084H11.5806Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,6 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M11.9999 2.51489C12.5522 2.51489 12.9999 2.96261 12.9999 3.51489V11.0002L20.4852 11.0002C21.0375 11.0002 21.4852 11.4479 21.4852 12.0002C21.4852 12.5525 21.0375 13.0002 20.4852 13.0002H12.9999V20.4855C12.9999 21.0377 12.5522 21.4855 11.9999 21.4855C11.4476 21.4855 10.9999 21.0377 10.9999 20.4855V13.0002H3.51465C2.96236 13.0002 2.51465 12.5525 2.51465 12.0002C2.51465 11.4479 2.96236 11.0002 3.51465 11.0002L10.9999 11.0002V3.51489C10.9999 2.96261 11.4476 2.51489 11.9999 2.51489Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 670 B |
@@ -1,6 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M8 3C8 2.44772 8.44772 2 9 2H15C15.5523 2 16 2.44772 16 3C16 3.55228 15.5523 4 15 4H9C8.44772 4 8 3.55228 8 3ZM4.99224 5H3C2.44772 5 2 5.44772 2 6C2 6.55228 2.44772 7 3 7H4.06445L4.70614 16.6254C4.75649 17.3809 4.79816 18.006 4.87287 18.5149C4.95066 19.0447 5.07405 19.5288 5.33109 19.98C5.73123 20.6824 6.33479 21.247 7.06223 21.5996C7.52952 21.826 8.0208 21.917 8.55459 21.9593C9.06728 22 9.69383 22 10.4509 22H13.5491C14.3062 22 14.9327 22 15.4454 21.9593C15.9792 21.917 16.4705 21.826 16.9378 21.5996C17.6652 21.247 18.2688 20.6824 18.6689 19.98C18.926 19.5288 19.0493 19.0447 19.1271 18.5149C19.2018 18.006 19.2435 17.3808 19.2939 16.6253L19.9356 7H21C21.5523 7 22 6.55228 22 6C22 5.44772 21.5523 5 21 5H19.0078C19.0019 4.99995 18.9961 4.99995 18.9903 5H5.00974C5.00392 4.99995 4.99809 4.99995 4.99224 5ZM17.9311 7H6.06889L6.69907 16.4528C6.75274 17.2578 6.78984 17.8034 6.85166 18.2243C6.9117 18.6333 6.98505 18.8429 7.06888 18.99C7.26895 19.3412 7.57072 19.6235 7.93444 19.7998C8.08684 19.8736 8.30086 19.9329 8.71286 19.9656C9.13703 19.9993 9.68385 20 10.4907 20H13.5093C14.3161 20 14.863 19.9993 15.2871 19.9656C15.6991 19.9329 15.9132 19.8736 16.0656 19.7998C16.4293 19.6235 16.7311 19.3412 16.9311 18.99C17.015 18.8429 17.0883 18.6333 17.1483 18.2243C17.2102 17.8034 17.2473 17.2578 17.3009 16.4528L17.9311 7Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,51 +0,0 @@
|
||||
import { cn } from "../utils/cn";
|
||||
|
||||
const COMMON_CLS = cn(
|
||||
"text-lg col-[1] row-[1] m-0 resize-none overflow-hidden whitespace-pre-wrap break-words border-none bg-transparent p-0"
|
||||
);
|
||||
|
||||
export function AutosizeTextarea(props: {
|
||||
id?: string;
|
||||
value?: string | null | undefined;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
onChange?: (e: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
autoFocus?: boolean;
|
||||
readOnly?: boolean;
|
||||
cursorPointer?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("grid w-full", props.className)}>
|
||||
<textarea
|
||||
id={props.id}
|
||||
className={cn(
|
||||
COMMON_CLS,
|
||||
"text-transparent caret-black dark:caret-slate-200"
|
||||
)}
|
||||
disabled={props.disabled}
|
||||
value={props.value ?? ""}
|
||||
rows={1}
|
||||
onChange={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
props.onChange?.(target.value);
|
||||
}}
|
||||
onFocus={props.onFocus}
|
||||
onBlur={props.onBlur}
|
||||
placeholder={props.placeholder}
|
||||
readOnly={props.readOnly}
|
||||
autoFocus={props.autoFocus && !props.readOnly}
|
||||
onKeyDown={props.onKeyDown}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(COMMON_CLS, "pointer-events-none select-none")}
|
||||
>
|
||||
{props.value}{" "}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import {
|
||||
ArrayLayoutProps,
|
||||
RankedTester,
|
||||
@@ -32,16 +32,16 @@ import {
|
||||
isPrimitiveArrayControl,
|
||||
or,
|
||||
rankWith,
|
||||
} from "@jsonforms/core";
|
||||
import { withJsonFormsArrayLayoutProps } from "@jsonforms/react";
|
||||
import { MaterialTableControl } from "./CustomArrayControlRenderer/MaterialTableControl";
|
||||
import { Hidden } from "@mui/material";
|
||||
import { DeleteDialog } from "./CustomArrayControlRenderer/DeleteDialog";
|
||||
} from '@jsonforms/core';
|
||||
import { withJsonFormsArrayLayoutProps } from '@jsonforms/react';
|
||||
import { MaterialTableControl } from './MaterialTableControl';
|
||||
import { Hidden } from '@mui/material';
|
||||
import { DeleteDialog } from './DeleteDialog';
|
||||
|
||||
export const MaterialArrayControlRenderer = (props: ArrayLayoutProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [path, setPath] = useState<string | undefined>(undefined);
|
||||
const [rowData, setRowData] = useState<number | undefined>(undefined);
|
||||
const [path, setPath] = useState(undefined);
|
||||
const [rowData, setRowData] = useState(undefined);
|
||||
const { removeItems, visible } = props;
|
||||
|
||||
const openDeleteDialog = useCallback(
|
||||
@@ -52,19 +52,16 @@ export const MaterialArrayControlRenderer = (props: ArrayLayoutProps) => {
|
||||
},
|
||||
[setOpen, setPath, setRowData]
|
||||
);
|
||||
|
||||
const deleteCancel = useCallback(() => setOpen(false), [setOpen]);
|
||||
const deleteConfirm = useCallback(() => {
|
||||
const p = path?.substring(0, path.lastIndexOf("."));
|
||||
if (p != null && rowData != null) removeItems?.(p, [rowData])();
|
||||
const p = path.substring(0, path.lastIndexOf('.'));
|
||||
removeItems(p, [rowData])();
|
||||
setOpen(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [setOpen, path, rowData]);
|
||||
|
||||
const deleteClose = useCallback(() => setOpen(false), [setOpen]);
|
||||
|
||||
return (
|
||||
<div className="control">
|
||||
<div className="control mt-4 mb-4">
|
||||
<Hidden xsUp={!visible}>
|
||||
<MaterialTableControl {...props} openDeleteDialog={openDeleteDialog} />
|
||||
<DeleteDialog
|
||||
@@ -72,21 +69,19 @@ export const MaterialArrayControlRenderer = (props: ArrayLayoutProps) => {
|
||||
onCancel={deleteCancel}
|
||||
onConfirm={deleteConfirm}
|
||||
onClose={deleteClose}
|
||||
acceptText={props.translations.deleteDialogAccept!}
|
||||
declineText={props.translations.deleteDialogDecline!}
|
||||
title={props.translations.deleteDialogTitle!}
|
||||
message={props.translations.deleteDialogMessage!}
|
||||
acceptText={props.translations.deleteDialogAccept}
|
||||
declineText={props.translations.deleteDialogDecline}
|
||||
title={props.translations.deleteDialogTitle}
|
||||
message={props.translations.deleteDialogMessage}
|
||||
/>
|
||||
</Hidden>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const materialArrayControlTester: RankedTester = rankWith(
|
||||
999,
|
||||
or(isObjectArrayControl, isPrimitiveArrayControl, isObjectArrayWithNesting)
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export default withJsonFormsArrayLayoutProps(MaterialArrayControlRenderer);
|
||||
export default withJsonFormsArrayLayoutProps(MaterialArrayControlRenderer);
|
||||
@@ -24,30 +24,29 @@
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
CellProps,
|
||||
isMultiLineControl,
|
||||
RankedTester,
|
||||
rankWith,
|
||||
} from "@jsonforms/core";
|
||||
import { withJsonFormsCellProps } from "@jsonforms/react";
|
||||
import {
|
||||
withVanillaCellProps,
|
||||
type VanillaRendererProps,
|
||||
} from "@jsonforms/vanilla-renderers";
|
||||
import merge from "lodash/merge";
|
||||
import { cn } from "../utils/cn";
|
||||
import { AutosizeTextarea } from "./AutosizeTextarea";
|
||||
} from '@jsonforms/core';
|
||||
import { withJsonFormsCellProps } from '@jsonforms/react';
|
||||
import { withVanillaCellProps, type VanillaRendererProps } from '@jsonforms/vanilla-renderers';
|
||||
import merge from 'lodash/merge';
|
||||
|
||||
export const TextAreaCell = (props: CellProps & VanillaRendererProps) => {
|
||||
const { data, className, id, enabled, config, uischema, path, handleChange } =
|
||||
props;
|
||||
const appliedUiSchemaOptions = merge({}, config, uischema.options);
|
||||
return (
|
||||
<AutosizeTextarea
|
||||
value={data || ""}
|
||||
onChange={(value) => handleChange(path, value === "" ? undefined : value)}
|
||||
className={cn("w-full text-lg", className)}
|
||||
<textarea
|
||||
value={data || ''}
|
||||
onChange={(ev) =>
|
||||
handleChange(path, ev.target.value === '' ? undefined : ev.target.value)
|
||||
}
|
||||
className={className + " control"}
|
||||
style={{width: "100%", fontSize: "18px"}}
|
||||
id={id}
|
||||
disabled={!enabled}
|
||||
autoFocus={appliedUiSchemaOptions.focus}
|
||||
@@ -60,8 +59,6 @@ export const TextAreaCell = (props: CellProps & VanillaRendererProps) => {
|
||||
* Tester for a multi-line string control.
|
||||
* @type {RankedTester}
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const textAreaCellTester: RankedTester = rankWith(2, isMultiLineControl);
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export default withJsonFormsCellProps(withVanillaCellProps(TextAreaCell));
|
||||
export default withJsonFormsCellProps(withVanillaCellProps(TextAreaCell));
|
||||
+9
-9
@@ -22,7 +22,7 @@
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
import React from "react";
|
||||
import React from 'react';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
} from "@mui/material";
|
||||
} from '@mui/material';
|
||||
|
||||
export interface DeleteDialogProps {
|
||||
open: boolean;
|
||||
@@ -62,23 +62,23 @@ export const DeleteDialog = React.memo(function DeleteDialog({
|
||||
open={open}
|
||||
keepMounted
|
||||
onClose={onClose}
|
||||
aria-labelledby="alert-dialog-confirmdelete-title"
|
||||
aria-describedby="alert-dialog-confirmdelete-description"
|
||||
aria-labelledby='alert-dialog-confirmdelete-title'
|
||||
aria-describedby='alert-dialog-confirmdelete-description'
|
||||
>
|
||||
<DialogTitle id="alert-dialog-confirmdelete-title">{title}</DialogTitle>
|
||||
<DialogTitle id='alert-dialog-confirmdelete-title'>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="alert-dialog-confirmdelete-description">
|
||||
<DialogContentText id='alert-dialog-confirmdelete-description'>
|
||||
{message}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onCancel} color="primary">
|
||||
<Button onClick={onCancel} color='primary'>
|
||||
{declineText}
|
||||
</Button>
|
||||
<Button onClick={onConfirm} color="primary">
|
||||
<Button onClick={onConfirm} color='primary'>
|
||||
{acceptText}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
// From https://github.com/eclipsesource/jsonforms/blob/master/packages/vanilla-renderers/src/cells/TextAreaCell.tsx
|
||||
|
||||
/*
|
||||
The MIT License
|
||||
|
||||
Copyright (c) 2017-2019 EclipseSource Munich
|
||||
https://github.com/eclipsesource/jsonforms
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
import {
|
||||
CellProps,
|
||||
isMultiLineControl,
|
||||
RankedTester,
|
||||
rankWith,
|
||||
} from "@jsonforms/core";
|
||||
import { withJsonFormsCellProps } from "@jsonforms/react";
|
||||
import {
|
||||
withVanillaCellProps,
|
||||
type VanillaRendererProps,
|
||||
} from "@jsonforms/vanilla-renderers";
|
||||
import merge from "lodash/merge";
|
||||
import { AutosizeTextarea } from "./AutosizeTextarea";
|
||||
import { cn } from "../utils/cn";
|
||||
|
||||
function tryJsonParse(str: string) {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
function tryJsonStringify(obj: unknown): string | unknown {
|
||||
try {
|
||||
return JSON.stringify(obj);
|
||||
} catch {
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
export const TextAreaCell = (props: CellProps & VanillaRendererProps) => {
|
||||
const { data, className, id, enabled, config, uischema, path, handleChange } =
|
||||
props;
|
||||
const appliedUiSchemaOptions = merge({}, config, uischema.options);
|
||||
return (
|
||||
<AutosizeTextarea
|
||||
value={typeof data === "object" ? tryJsonStringify(data) : data ?? ""}
|
||||
onChange={(value) =>
|
||||
handleChange(path, value === "" ? undefined : tryJsonParse(value))
|
||||
}
|
||||
className={cn("w-full text-lg", className)}
|
||||
id={id}
|
||||
disabled={!enabled}
|
||||
autoFocus={appliedUiSchemaOptions.focus}
|
||||
placeholder={appliedUiSchemaOptions.placeholder}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tester for a multi-line string control.
|
||||
* @type {RankedTester}
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const textAreaCellTester: RankedTester = rankWith(2, isMultiLineControl);
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export default withJsonFormsCellProps(withVanillaCellProps(TextAreaCell));
|
||||
+54
-70
@@ -22,18 +22,16 @@
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import union from "lodash/union";
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import union from 'lodash/union';
|
||||
import {
|
||||
DispatchCell,
|
||||
JsonFormsStateContext,
|
||||
useJsonForms,
|
||||
} from "@jsonforms/react";
|
||||
import startCase from "lodash/startCase";
|
||||
import range from "lodash/range";
|
||||
import React, { Fragment, useMemo } from "react";
|
||||
import TrashIcon from "../../assets/TrashIcon.svg?react";
|
||||
|
||||
} from '@jsonforms/react';
|
||||
import startCase from 'lodash/startCase';
|
||||
import range from 'lodash/range';
|
||||
import React, { Fragment, useMemo } from 'react';
|
||||
import {
|
||||
FormHelperText,
|
||||
Grid,
|
||||
@@ -45,7 +43,7 @@ import {
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrayLayoutProps,
|
||||
ControlElement,
|
||||
@@ -58,30 +56,32 @@ import {
|
||||
JsonFormsCellRendererRegistryEntry,
|
||||
encode,
|
||||
ArrayTranslations,
|
||||
} from "@jsonforms/core";
|
||||
import ArrowDownward from "@mui/icons-material/ArrowDownward";
|
||||
import ArrowUpward from "@mui/icons-material/ArrowUpward";
|
||||
} from '@jsonforms/core';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ArrowDownward from '@mui/icons-material/ArrowDownward';
|
||||
import ArrowUpward from '@mui/icons-material/ArrowUpward';
|
||||
|
||||
import { WithDeleteDialogSupport } from "./DeleteDialog";
|
||||
import NoBorderTableCell from "./NoBorderTableCell";
|
||||
import TableToolbar from "./TableToolbar";
|
||||
import merge from "lodash/merge";
|
||||
import { WithDeleteDialogSupport } from './DeleteDialog';
|
||||
import NoBorderTableCell from './NoBorderTableCell';
|
||||
import TableToolbar from './TableToolbar';
|
||||
import { ErrorObject } from 'ajv';
|
||||
import merge from 'lodash/merge';
|
||||
|
||||
// we want a cell that doesn't automatically span
|
||||
const styles = {
|
||||
fixedCell: {
|
||||
width: "150px",
|
||||
height: "50px",
|
||||
width: '150px',
|
||||
height: '50px',
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
textAlign: "center",
|
||||
textAlign: 'center',
|
||||
},
|
||||
fixedCellSmall: {
|
||||
width: "50px",
|
||||
height: "50px",
|
||||
width: '50px',
|
||||
height: '50px',
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
textAlign: "center",
|
||||
textAlign: 'center',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -92,7 +92,7 @@ const generateCells = (
|
||||
enabled: boolean,
|
||||
cells?: JsonFormsCellRendererRegistryEntry[]
|
||||
) => {
|
||||
if (schema.type === "object") {
|
||||
if (schema.type === 'object') {
|
||||
return getValidColumnProps(schema).map((prop) => {
|
||||
const cellPath = Paths.compose(rowPath, prop);
|
||||
const props = {
|
||||
@@ -120,15 +120,15 @@ const generateCells = (
|
||||
|
||||
const getValidColumnProps = (scopedSchema: JsonSchema) => {
|
||||
if (
|
||||
scopedSchema.type === "object" &&
|
||||
typeof scopedSchema.properties === "object"
|
||||
scopedSchema.type === 'object' &&
|
||||
typeof scopedSchema.properties === 'object'
|
||||
) {
|
||||
return Object.keys(scopedSchema.properties).filter(
|
||||
(prop) => scopedSchema.properties?.[prop].type !== "array"
|
||||
(prop) => scopedSchema.properties[prop].type !== 'array'
|
||||
);
|
||||
}
|
||||
// primitives
|
||||
return [""];
|
||||
return [''];
|
||||
};
|
||||
|
||||
export interface EmptyTableProps {
|
||||
@@ -139,7 +139,7 @@ export interface EmptyTableProps {
|
||||
const EmptyTable = ({ numColumns, translations }: EmptyTableProps) => (
|
||||
<TableRow>
|
||||
<NoBorderTableCell colSpan={numColumns}>
|
||||
<Typography align="center">{translations.noDataMessage}</Typography>
|
||||
<Typography align='center'>{translations.noDataMessage}</Typography>
|
||||
</NoBorderTableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -151,18 +151,7 @@ interface TableHeaderCellProps {
|
||||
const TableHeaderCell = React.memo(function TableHeaderCell({
|
||||
title,
|
||||
}: TableHeaderCellProps) {
|
||||
return (
|
||||
<TableCell
|
||||
sx={{
|
||||
color: "hsl(var(--ls-gray-100))",
|
||||
borderBottomColor: "hsl(var(--divider-700))",
|
||||
px: 0,
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</TableCell>
|
||||
);
|
||||
return <TableCell>{title}</TableCell>;
|
||||
});
|
||||
|
||||
interface NonEmptyCellProps extends OwnPropsOfNonEmptyCell {
|
||||
@@ -185,21 +174,21 @@ const ctxToNonEmptyCellProps = (
|
||||
): NonEmptyCellProps => {
|
||||
const path =
|
||||
ownProps.rowPath +
|
||||
(ownProps.schema.type === "object" ? "." + ownProps.propName : "");
|
||||
(ownProps.schema.type === 'object' ? '.' + ownProps.propName : '');
|
||||
const errors = formatErrorMessage(
|
||||
union(
|
||||
errorsAt(
|
||||
path,
|
||||
ownProps.schema,
|
||||
(p) => p === path
|
||||
)(ctx.core?.errors ?? []).map((error) => error.message) as string[]
|
||||
)(ctx.core.errors).map((error: ErrorObject) => error.message)
|
||||
)
|
||||
);
|
||||
return {
|
||||
rowPath: ownProps.rowPath,
|
||||
propName: ownProps.propName,
|
||||
schema: ownProps.schema,
|
||||
rootSchema: ctx.core?.schema ?? {},
|
||||
rootSchema: ctx.core.schema,
|
||||
errors,
|
||||
path,
|
||||
enabled: ownProps.enabled,
|
||||
@@ -209,7 +198,7 @@ const ctxToNonEmptyCellProps = (
|
||||
};
|
||||
|
||||
const controlWithoutLabel = (scope: string): ControlElement => ({
|
||||
type: "Control",
|
||||
type: 'Control',
|
||||
scope: scope,
|
||||
label: false,
|
||||
});
|
||||
@@ -237,15 +226,15 @@ const NonEmptyCellComponent = React.memo(function NonEmptyCellComponent({
|
||||
isValid,
|
||||
}: NonEmptyCellComponentProps) {
|
||||
return (
|
||||
<NoBorderTableCell sx={{ color: "hsl(var(--ls-black))" }}>
|
||||
<NoBorderTableCell>
|
||||
{schema.properties ? (
|
||||
<DispatchCell
|
||||
schema={Resolve.schema(
|
||||
schema,
|
||||
`#/properties/${encode(propName!)}`,
|
||||
`#/properties/${encode(propName)}`,
|
||||
rootSchema
|
||||
)}
|
||||
uischema={controlWithoutLabel(`#/properties/${encode(propName!)}`)}
|
||||
uischema={controlWithoutLabel(`#/properties/${encode(propName)}`)}
|
||||
path={path}
|
||||
enabled={enabled}
|
||||
renderers={renderers}
|
||||
@@ -254,7 +243,7 @@ const NonEmptyCellComponent = React.memo(function NonEmptyCellComponent({
|
||||
) : (
|
||||
<DispatchCell
|
||||
schema={schema}
|
||||
uischema={controlWithoutLabel("#")}
|
||||
uischema={controlWithoutLabel('#')}
|
||||
path={path}
|
||||
enabled={enabled}
|
||||
renderers={renderers}
|
||||
@@ -314,16 +303,16 @@ const NonEmptyRowComponent = ({
|
||||
);
|
||||
return (
|
||||
<TableRow key={childPath} hover>
|
||||
{generateCells(NonEmptyCell as any, schema, childPath, enabled, cells)}
|
||||
{generateCells(NonEmptyCell, schema, childPath, enabled, cells)}
|
||||
{enabled ? (
|
||||
<NoBorderTableCell
|
||||
style={showSortButtons ? styles.fixedCell : styles.fixedCellSmall}
|
||||
>
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
alignItems="center"
|
||||
direction='row'
|
||||
justifyContent='flex-end'
|
||||
alignItems='center'
|
||||
>
|
||||
{showSortButtons ? (
|
||||
<Fragment>
|
||||
@@ -332,7 +321,7 @@ const NonEmptyRowComponent = ({
|
||||
aria-label={translations.upAriaLabel}
|
||||
onClick={moveUp}
|
||||
disabled={!enableUp}
|
||||
size="large"
|
||||
size='large'
|
||||
>
|
||||
<ArrowUpward />
|
||||
</IconButton>
|
||||
@@ -342,7 +331,7 @@ const NonEmptyRowComponent = ({
|
||||
aria-label={translations.downAriaLabel}
|
||||
onClick={moveDown}
|
||||
disabled={!enableDown}
|
||||
size="large"
|
||||
size='large'
|
||||
>
|
||||
<ArrowDownward />
|
||||
</IconButton>
|
||||
@@ -353,10 +342,9 @@ const NonEmptyRowComponent = ({
|
||||
<IconButton
|
||||
aria-label={translations.removeAriaLabel}
|
||||
onClick={() => openDeleteDialog(childPath, rowIndex)}
|
||||
size="large"
|
||||
sx={{ p: 1 }}
|
||||
size='large'
|
||||
>
|
||||
<TrashIcon className="text-ls-black" />
|
||||
<DeleteIcon style={{ color: 'white' }} />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -416,8 +404,8 @@ const TableRows = ({
|
||||
rowIndex={index}
|
||||
schema={schema}
|
||||
openDeleteDialog={openDeleteDialog}
|
||||
moveUpCreator={moveUp ?? (() => () => {})}
|
||||
moveDownCreator={moveDown ?? (() => () => {})}
|
||||
moveUpCreator={moveUp}
|
||||
moveDownCreator={moveDown}
|
||||
enableUp={index !== 0}
|
||||
enableDown={index !== data - 1}
|
||||
showSortButtons={
|
||||
@@ -456,15 +444,15 @@ export class MaterialTableControl extends React.Component<
|
||||
} = this.props;
|
||||
|
||||
const controlElement = uischema as ControlElement;
|
||||
const isObjectSchema = schema.type === "object";
|
||||
const isObjectSchema = schema.type === 'object';
|
||||
const headerCells: any = isObjectSchema
|
||||
? generateCells(TableHeaderCell as any, schema, path, enabled, cells)
|
||||
? generateCells(TableHeaderCell, schema, path, enabled, cells)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Hidden xsUp={!visible}>
|
||||
<Table>
|
||||
<TableHead sx={{ borderBottomColor: "hsl(var(--divider-700))" }}>
|
||||
<TableHead>
|
||||
<TableToolbar
|
||||
errors={errors}
|
||||
label={label}
|
||||
@@ -480,23 +468,19 @@ export class MaterialTableControl extends React.Component<
|
||||
{isObjectSchema && (
|
||||
<TableRow>
|
||||
{headerCells}
|
||||
{enabled ? (
|
||||
<TableCell
|
||||
sx={{ borderBottomColor: "hsl(var(--divider-700))" }}
|
||||
/>
|
||||
) : null}
|
||||
{enabled ? <TableCell /> : null}
|
||||
</TableRow>
|
||||
)}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<TableRows
|
||||
openDeleteDialog={openDeleteDialog}
|
||||
translations={translations}
|
||||
{...this.props}
|
||||
openDeleteDialog={this.props.openDeleteDialog ?? openDeleteDialog}
|
||||
translations={this.props.translations ?? translations}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Hidden>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -22,18 +22,18 @@
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
import { styled } from "@mui/material/styles";
|
||||
import { TableCell } from "@mui/material";
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { TableCell } from '@mui/material';
|
||||
import React from 'react';
|
||||
|
||||
const StyledTableCell = styled(TableCell)({
|
||||
borderBottom: "none",
|
||||
borderBottom: 'none',
|
||||
color: "white",
|
||||
fill: "white",
|
||||
color: "inherit",
|
||||
padding: 0,
|
||||
});
|
||||
|
||||
const NoBorderTableCell = ({ children, ...otherProps }: any) => (
|
||||
<StyledTableCell {...otherProps}>{children}</StyledTableCell>
|
||||
);
|
||||
|
||||
export default NoBorderTableCell;
|
||||
export default NoBorderTableCell;
|
||||
@@ -1,164 +0,0 @@
|
||||
import { Drawer } from "vaul";
|
||||
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import CodeIcon from "../assets/CodeIcon.svg?react";
|
||||
import PadlockIcon from "../assets/PadlockIcon.svg?react";
|
||||
import CopyIcon from "../assets/CopyIcon.svg?react";
|
||||
import CheckCircleIcon from "../assets/CheckCircleIcon.svg?react";
|
||||
import {
|
||||
compressToEncodedURIComponent,
|
||||
decompressFromEncodedURIComponent,
|
||||
} from "lz-string";
|
||||
|
||||
const URL_LENGTH_LIMIT = 2000;
|
||||
|
||||
export function getStateFromUrl(path: string) {
|
||||
let configFromUrl = null;
|
||||
let basePath = path;
|
||||
if (basePath.endsWith("/")) {
|
||||
basePath = basePath.slice(0, -1);
|
||||
}
|
||||
|
||||
if (basePath.endsWith("/playground")) {
|
||||
basePath = basePath.slice(0, -"/playground".length);
|
||||
}
|
||||
|
||||
// check if we can omit the last segment
|
||||
const [configHash, c, ...rest] = basePath.split("/").reverse();
|
||||
if (c === "c") {
|
||||
basePath = rest.reverse().join("/");
|
||||
try {
|
||||
configFromUrl = JSON.parse(decompressFromEncodedURIComponent(configHash));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
return { basePath, configFromUrl };
|
||||
}
|
||||
|
||||
function CopyButton(props: { value: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const cbRef = useRef<number | null>(null);
|
||||
|
||||
function toggleCopied() {
|
||||
setCopied(true);
|
||||
|
||||
if (cbRef.current != null) window.clearTimeout(cbRef.current);
|
||||
cbRef.current = window.setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cbRef.current != null) {
|
||||
window.clearTimeout(cbRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
className="px-3 py-1"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(props.value).then(toggleCopied);
|
||||
}}
|
||||
>
|
||||
{copied ? <CheckCircleIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShareDialog(props: { config: unknown; children: ReactNode }) {
|
||||
const hash = useMemo(() => {
|
||||
return compressToEncodedURIComponent(JSON.stringify(props.config));
|
||||
}, [props.config]);
|
||||
|
||||
const state = getStateFromUrl(window.location.href);
|
||||
|
||||
// get base URL
|
||||
const targetUrl = `${state.basePath}/c/${hash}`;
|
||||
|
||||
// .../c/[hash]/playground
|
||||
const playgroundUrl = `${targetUrl}/playground`;
|
||||
|
||||
// cURL, JS: .../c/[hash]/invoke
|
||||
// Python: .../c/[hash]
|
||||
const invokeUrl = `${targetUrl}/invoke`;
|
||||
|
||||
const pythonSnippet = `
|
||||
from langserve import RemoteRunnable
|
||||
|
||||
chain = RemoteRunnable("${targetUrl}")
|
||||
chain.invoke({ ... })
|
||||
`;
|
||||
|
||||
const typescriptSnippet = `
|
||||
import { RemoteRunnable } from "langchain/runnables/remote";
|
||||
|
||||
const chain = new RemoteRunnable({ url: \`${invokeUrl}\` });
|
||||
const result = await chain.invoke({ ... });
|
||||
`;
|
||||
|
||||
return (
|
||||
<Drawer.Root>
|
||||
<Drawer.Trigger asChild>{props.children}</Drawer.Trigger>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
|
||||
<Drawer.Content className="flex justify-center items-center mt-24 fixed bottom-0 left-0 right-0 text-ls-black !pointer-events-none after:!bg-background">
|
||||
<div className="p-4 bg-background max-w-[calc(800px-2rem)] rounded-t-2xl border border-divider-500 border-b-background pointer-events-auto">
|
||||
<h3 className="text-xl font-medium">Share</h3>
|
||||
|
||||
<hr className="border-divider-500 my-4 -mx-4" />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{playgroundUrl.length < URL_LENGTH_LIMIT && (
|
||||
<div className="flex flex-col gap-2 p-3 rounded-2xl dark:bg-[#2C2C2E] bg-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 flex items-center justify-center text-center text-sm bg-background rounded-xl">
|
||||
🦜
|
||||
</div>
|
||||
<span className="font-semibold">Playground</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[auto,1fr,auto] dark:bg-[#111111] bg-white rounded-xl text-sm items-center">
|
||||
<PadlockIcon className="mx-3" />
|
||||
<div className="overflow-auto whitespace-nowrap py-3 no-scrollbar text-ls-gray-100">
|
||||
{playgroundUrl.split("://")[1]}
|
||||
PadlockIcon
|
||||
</div>
|
||||
<CopyButton value={playgroundUrl} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 p-3 rounded-2xl dark:bg-[#2C2C2E] bg-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 flex items-center justify-center text-center text-sm bg-background rounded-xl">
|
||||
<CodeIcon />
|
||||
</div>
|
||||
<span className="font-semibold">Get the code</span>
|
||||
</div>
|
||||
|
||||
{targetUrl.length < URL_LENGTH_LIMIT && (
|
||||
<div className="grid grid-cols-[1fr,auto] dark:bg-[#111111] bg-white rounded-xl text-sm items-center">
|
||||
<div className="overflow-auto whitespace-nowrap px-3 py-3 no-scrollbar text-ls-gray-100">
|
||||
Python SDK
|
||||
</div>
|
||||
<CopyButton value={pythonSnippet.trim()} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invokeUrl.length < URL_LENGTH_LIMIT && (
|
||||
<div className="grid grid-cols-[1fr,auto] dark:bg-[#111111] bg-white rounded-xl text-sm items-center">
|
||||
<div className="overflow-auto whitespace-nowrap px-3 py-3 no-scrollbar text-ls-gray-100">
|
||||
TypeScript SDK
|
||||
</div>
|
||||
|
||||
<CopyButton value={typescriptSnippet.trim()} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
);
|
||||
}
|
||||
+34
-24
@@ -22,17 +22,17 @@
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
import React from "react";
|
||||
import React from 'react';
|
||||
import {
|
||||
ControlElement,
|
||||
createDefaultValue,
|
||||
JsonSchema,
|
||||
ArrayTranslations,
|
||||
} from "@jsonforms/core";
|
||||
import { IconButton, TableRow, Tooltip } from "@mui/material";
|
||||
import PlusIcon from "../../assets/PlusIcon.svg?react";
|
||||
import ValidationIcon from "./ValidationIcon";
|
||||
import NoBorderTableCell from "./NoBorderTableCell";
|
||||
} from '@jsonforms/core';
|
||||
import { IconButton, TableRow, Tooltip, Grid, Typography } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import ValidationIcon from './ValidationIcon';
|
||||
import NoBorderTableCell from './NoBorderTableCell';
|
||||
|
||||
export interface MaterialTableToolbarProps {
|
||||
numColumns: number;
|
||||
@@ -64,32 +64,42 @@ const TableToolbar = React.memo(function TableToolbar({
|
||||
}: MaterialTableToolbarProps) {
|
||||
return (
|
||||
<TableRow>
|
||||
<NoBorderTableCell colSpan={numColumns} sx={{ verticalAlign: "top" }}>
|
||||
<div className="flex items-center gap-2">
|
||||
{label && (
|
||||
<span className="text-xs uppercase font-semibold text-ls-gray-100">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{errors.length !== 0 && (
|
||||
<ValidationIcon id="tooltip-validation" errorMessages={errors} />
|
||||
)}
|
||||
</div>
|
||||
<NoBorderTableCell colSpan={numColumns}>
|
||||
<Grid
|
||||
container
|
||||
justifyContent={'flex-start'}
|
||||
alignItems={'center'}
|
||||
spacing={2}
|
||||
>
|
||||
<Grid item>
|
||||
<Typography variant={'h6'}>{label}</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
{errors.length !== 0 && (
|
||||
<Grid item>
|
||||
<ValidationIcon
|
||||
id='tooltip-validation'
|
||||
errorMessages={errors}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</NoBorderTableCell>
|
||||
{enabled ? (
|
||||
<NoBorderTableCell align="right" style={fixedCellSmall}>
|
||||
<NoBorderTableCell align='right' style={fixedCellSmall}>
|
||||
<Tooltip
|
||||
id="tooltip-add"
|
||||
id='tooltip-add'
|
||||
title={translations.addTooltip}
|
||||
placement="bottom"
|
||||
placement='bottom'
|
||||
>
|
||||
<IconButton
|
||||
aria-label={translations.addAriaLabel}
|
||||
onClick={addItem(path, createDefaultValue(schema))}
|
||||
size="large"
|
||||
sx={{ p: 1 }}
|
||||
size='large'
|
||||
style={{ color: 'white' }}
|
||||
>
|
||||
<PlusIcon className="text-ls-black" />
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</NoBorderTableCell>
|
||||
@@ -98,4 +108,4 @@ const TableToolbar = React.memo(function TableToolbar({
|
||||
);
|
||||
});
|
||||
|
||||
export default TableToolbar;
|
||||
export default TableToolbar;
|
||||
+6
-6
@@ -22,10 +22,10 @@
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
import React from "react";
|
||||
import React from 'react';
|
||||
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
|
||||
import { Badge, Tooltip, styled } from "@mui/material";
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
||||
import { Badge, Tooltip, styled } from '@mui/material';
|
||||
|
||||
const StyledBadge = styled(Badge)(({ theme }: any) => ({
|
||||
color: theme.palette.error.main,
|
||||
@@ -39,11 +39,11 @@ export interface ValidationProps {
|
||||
const ValidationIcon: React.FC<ValidationProps> = ({ errorMessages, id }) => {
|
||||
return (
|
||||
<Tooltip id={id} title={errorMessages}>
|
||||
<StyledBadge badgeContent={errorMessages.split("\n").length}>
|
||||
<ErrorOutlineIcon color="inherit" />
|
||||
<StyledBadge badgeContent={errorMessages.split('\n').length}>
|
||||
<ErrorOutlineIcon color='inherit' />
|
||||
</StyledBadge>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValidationIcon;
|
||||
export default ValidationIcon;
|
||||
@@ -1,4 +1,4 @@
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(<App/>);
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { resolveApiUrl } from "./utils/url";
|
||||
import { simplifySchema } from "./utils/simplifySchema";
|
||||
import { JsonFormsCore } from "@jsonforms/core";
|
||||
import { compressToEncodedURIComponent } from "lz-string";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -14,9 +10,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export function useSchemas(
|
||||
configData: Pick<JsonFormsCore, "data" | "errors"> & { defaults: boolean }
|
||||
) {
|
||||
export function useSchemas() {
|
||||
const [schemas, setSchemas] = useState({
|
||||
config: null,
|
||||
input: null,
|
||||
@@ -26,22 +20,14 @@ export function useSchemas(
|
||||
async function save() {
|
||||
if (import.meta.env.DEV) {
|
||||
const [config, input] = await Promise.all([
|
||||
fetch(resolveApiUrl("/config_schema"))
|
||||
.then((r) => r.json())
|
||||
.then(simplifySchema),
|
||||
fetch(resolveApiUrl("/input_schema"))
|
||||
.then((r) => r.json())
|
||||
.then(simplifySchema),
|
||||
fetch(resolveApiUrl("/config_schema")).then((r) => r.json()),
|
||||
fetch(resolveApiUrl("/input_schema")).then((r) => r.json()),
|
||||
]);
|
||||
setSchemas({ config, input });
|
||||
} else {
|
||||
setSchemas({
|
||||
config: window.CONFIG_SCHEMA
|
||||
? await simplifySchema(window.CONFIG_SCHEMA)
|
||||
: null,
|
||||
input: window.INPUT_SCHEMA
|
||||
? await simplifySchema(window.INPUT_SCHEMA)
|
||||
: null,
|
||||
config: window.CONFIG_SCHEMA ?? null,
|
||||
input: window.INPUT_SCHEMA ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -49,23 +35,5 @@ export function useSchemas(
|
||||
save();
|
||||
}, []);
|
||||
|
||||
const [debouncedConfigData] = useDebounce(configData, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (!debouncedConfigData.defaults) {
|
||||
fetch(
|
||||
resolveApiUrl(
|
||||
`c/${compressToEncodedURIComponent(
|
||||
JSON.stringify(debouncedConfigData.data)
|
||||
)}/input_schema`
|
||||
)
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then(simplifySchema)
|
||||
.then((input) => setSchemas((current) => ({ ...current, input })))
|
||||
.catch(() => {}); // ignore errors, eg. due to incomplete config
|
||||
}
|
||||
}, [debouncedConfigData]);
|
||||
|
||||
return schemas;
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import { ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export const JsonRefs: {
|
||||
resolveRefs(schema: any): Promise<{ resolved: any }>;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { JsonRefs } from "./json-refs";
|
||||
|
||||
// jsonforms doesn't support schemas with root $ref
|
||||
// so we resolve root $ref and replace it with the actual schema
|
||||
export function simplifySchema(schema: any) {
|
||||
return JsonRefs.resolveRefs(schema).then((r: any) => r.resolved);
|
||||
}
|
||||
+5048
-2782
File diff suppressed because it is too large
Load Diff
+33
-81
@@ -382,16 +382,6 @@ def add_routes(
|
||||
}
|
||||
}
|
||||
|
||||
* error - for signaling an error in the stream, also ends the stream.
|
||||
|
||||
{
|
||||
"event": "error",
|
||||
"data": {
|
||||
"status_code": 500,
|
||||
"message": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
|
||||
* end - for signaling the end of the stream.
|
||||
|
||||
This helps the client to know when to stop listening for events and
|
||||
@@ -412,24 +402,12 @@ def add_routes(
|
||||
|
||||
async def _stream() -> AsyncIterator[dict]:
|
||||
"""Stream the output of the runnable."""
|
||||
try:
|
||||
async for chunk in runnable.astream(
|
||||
input_,
|
||||
config=config,
|
||||
):
|
||||
yield {"data": simple_dumps(chunk), "event": "data"}
|
||||
yield {"event": "end"}
|
||||
except BaseException:
|
||||
yield {
|
||||
"event": "error",
|
||||
# Do not expose the error message to the client since
|
||||
# the message may contain sensitive information.
|
||||
# We'll add client side errors for validation as well.
|
||||
"data": json.dumps(
|
||||
{"status_code": 500, "message": "Internal Server Error"}
|
||||
),
|
||||
}
|
||||
raise
|
||||
async for chunk in runnable.astream(
|
||||
input_,
|
||||
config=config,
|
||||
):
|
||||
yield {"data": simple_dumps(chunk), "event": "data"}
|
||||
yield {"event": "end"}
|
||||
|
||||
return EventSourceResponse(_stream())
|
||||
|
||||
@@ -463,16 +441,6 @@ def add_routes(
|
||||
}
|
||||
}
|
||||
|
||||
* error - for signaling an error in the stream, also ends the stream.
|
||||
|
||||
{
|
||||
"event": "error",
|
||||
"data": {
|
||||
"status_code": 500,
|
||||
"message": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
|
||||
* end - for signaling the end of the stream.
|
||||
|
||||
This helps the client to know when to stop listening for events and
|
||||
@@ -496,43 +464,31 @@ def add_routes(
|
||||
|
||||
async def _stream_log() -> AsyncIterator[dict]:
|
||||
"""Stream the output of the runnable."""
|
||||
try:
|
||||
async for chunk in runnable.astream_log(
|
||||
input_,
|
||||
config=config,
|
||||
diff=True,
|
||||
include_names=stream_log_request.include_names,
|
||||
include_types=stream_log_request.include_types,
|
||||
include_tags=stream_log_request.include_tags,
|
||||
exclude_names=stream_log_request.exclude_names,
|
||||
exclude_types=stream_log_request.exclude_types,
|
||||
exclude_tags=stream_log_request.exclude_tags,
|
||||
):
|
||||
if not isinstance(chunk, RunLogPatch):
|
||||
raise AssertionError(
|
||||
f"Expected a RunLog instance got {type(chunk)}"
|
||||
)
|
||||
data = {
|
||||
"ops": chunk.ops,
|
||||
}
|
||||
|
||||
# Temporary adapter
|
||||
yield {
|
||||
"data": simple_dumps(data),
|
||||
"event": "data",
|
||||
}
|
||||
yield {"event": "end"}
|
||||
except BaseException:
|
||||
yield {
|
||||
"event": "error",
|
||||
# Do not expose the error message to the client since
|
||||
# the message may contain sensitive information.
|
||||
# We'll add client side errors for validation as well.
|
||||
"data": json.dumps(
|
||||
{"status_code": 500, "message": "Internal Server Error"}
|
||||
),
|
||||
async for chunk in runnable.astream_log(
|
||||
input_,
|
||||
config=config,
|
||||
diff=True,
|
||||
include_names=stream_log_request.include_names,
|
||||
include_types=stream_log_request.include_types,
|
||||
include_tags=stream_log_request.include_tags,
|
||||
exclude_names=stream_log_request.exclude_names,
|
||||
exclude_types=stream_log_request.exclude_types,
|
||||
exclude_tags=stream_log_request.exclude_tags,
|
||||
):
|
||||
if not isinstance(chunk, RunLogPatch):
|
||||
raise AssertionError(
|
||||
f"Expected a RunLog instance got {type(chunk)}"
|
||||
)
|
||||
data = {
|
||||
"ops": chunk.ops,
|
||||
}
|
||||
raise
|
||||
|
||||
# Temporary adapter
|
||||
yield {
|
||||
"data": simple_dumps(data),
|
||||
"event": "data",
|
||||
}
|
||||
yield {"event": "end"}
|
||||
|
||||
return EventSourceResponse(_stream_log())
|
||||
|
||||
@@ -552,13 +508,9 @@ def add_routes(
|
||||
@app.get(f"{namespace}/output_schema")
|
||||
async def output_schema(config_hash: str = "") -> Any:
|
||||
"""Return the output schema of the runnable."""
|
||||
return (
|
||||
runnable.with_config(
|
||||
_unpack_config(config_hash, keys=config_keys, model=ConfigPayload)
|
||||
).output_schema.schema()
|
||||
if output_type_ == "auto"
|
||||
else output_type_.schema()
|
||||
)
|
||||
return runnable.with_config(
|
||||
_unpack_config(config_hash, keys=config_keys, model=ConfigPayload)
|
||||
).output_schema.schema()
|
||||
|
||||
@app.get(namespace + "/c/{config_hash}/config_schema", tags=["config"])
|
||||
@app.get(f"{namespace}/config_schema")
|
||||
|
||||
Generated
+19
-19
@@ -827,20 +827,20 @@ tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipyth
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.104.0"
|
||||
version = "0.103.2"
|
||||
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "fastapi-0.104.0-py3-none-any.whl", hash = "sha256:456482c1178fb7beb2814b88e1885bc49f9a81f079665016feffe3e1c6a7663e"},
|
||||
{file = "fastapi-0.104.0.tar.gz", hash = "sha256:9c44de45693ae037b0c6914727a29c49a40668432b67c859a87851fc6a7b74c6"},
|
||||
{file = "fastapi-0.103.2-py3-none-any.whl", hash = "sha256:3270de872f0fe9ec809d4bd3d4d890c6d5cc7b9611d721d6438f9dacc8c4ef2e"},
|
||||
{file = "fastapi-0.103.2.tar.gz", hash = "sha256:75a11f6bfb8fc4d2bec0bd710c2d5f2829659c0e8c0afd5560fdda6ce25ec653"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=3.7.1,<4.0.0"
|
||||
pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0"
|
||||
starlette = ">=0.27.0,<0.28.0"
|
||||
typing-extensions = ">=4.8.0"
|
||||
typing-extensions = ">=4.5.0"
|
||||
|
||||
[package.extras]
|
||||
all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.5)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
|
||||
@@ -1654,13 +1654,13 @@ test = ["hatch", "ipykernel", "openapi-core (>=0.18.0,<0.19.0)", "openapi-spec-v
|
||||
|
||||
[[package]]
|
||||
name = "langchain"
|
||||
version = "0.0.319"
|
||||
version = "0.0.316"
|
||||
description = "Building applications with LLMs through composability"
|
||||
optional = false
|
||||
python-versions = ">=3.8.1,<4.0"
|
||||
files = [
|
||||
{file = "langchain-0.0.319-py3-none-any.whl", hash = "sha256:a61448fd418ff9478f2be3477c9c92acbf6b6acd8163c58c994a6158d4d116f8"},
|
||||
{file = "langchain-0.0.319.tar.gz", hash = "sha256:4fe5025e5fd48dcf8e02107fefe173ba997af3c8960871cc4a4467e24bb89375"},
|
||||
{file = "langchain-0.0.316-py3-none-any.whl", hash = "sha256:997d2ecdaf481517c90dcea86f93b98327546c357ac8f581f06d7d782a2eda2d"},
|
||||
{file = "langchain-0.0.316.tar.gz", hash = "sha256:732c958d41e22a5bb55f8c0cb70914793f083d175739d476e5cbbc7715703153"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -1685,7 +1685,7 @@ cli = ["typer (>=0.9.0,<0.10.0)"]
|
||||
cohere = ["cohere (>=4,<5)"]
|
||||
docarray = ["docarray[hnswlib] (>=0.32.0,<0.33.0)"]
|
||||
embeddings = ["sentence-transformers (>=2,<3)"]
|
||||
extended-testing = ["aiosqlite (>=0.19.0,<0.20.0)", "amazon-textract-caller (<2)", "anthropic (>=0.3.11,<0.4.0)", "arxiv (>=1.4,<2.0)", "assemblyai (>=0.17.0,<0.18.0)", "atlassian-python-api (>=3.36.0,<4.0.0)", "beautifulsoup4 (>=4,<5)", "bibtexparser (>=1.4.0,<2.0.0)", "cassio (>=0.1.0,<0.2.0)", "chardet (>=5.1.0,<6.0.0)", "dashvector (>=1.0.1,<2.0.0)", "esprima (>=4.0.1,<5.0.0)", "faiss-cpu (>=1,<2)", "feedparser (>=6.0.10,<7.0.0)", "geopandas (>=0.13.1,<0.14.0)", "gitpython (>=3.1.32,<4.0.0)", "gql (>=3.4.1,<4.0.0)", "html2text (>=2020.1.16,<2021.0.0)", "jinja2 (>=3,<4)", "jq (>=1.4.1,<2.0.0)", "lxml (>=4.9.2,<5.0.0)", "markdownify (>=0.11.6,<0.12.0)", "motor (>=3.3.1,<4.0.0)", "mwparserfromhell (>=0.6.4,<0.7.0)", "mwxml (>=0.3.3,<0.4.0)", "newspaper3k (>=0.2.8,<0.3.0)", "numexpr (>=2.8.6,<3.0.0)", "openai (>=0,<1)", "openapi-pydantic (>=0.3.2,<0.4.0)", "pandas (>=2.0.1,<3.0.0)", "pdfminer-six (>=20221105,<20221106)", "pgvector (>=0.1.6,<0.2.0)", "psychicapi (>=0.8.0,<0.9.0)", "py-trello (>=0.19.0,<0.20.0)", "pymupdf (>=1.22.3,<2.0.0)", "pypdf (>=3.4.0,<4.0.0)", "pypdfium2 (>=4.10.0,<5.0.0)", "pyspark (>=3.4.0,<4.0.0)", "rank-bm25 (>=0.2.2,<0.3.0)", "rapidfuzz (>=3.1.1,<4.0.0)", "rapidocr-onnxruntime (>=1.3.2,<2.0.0)", "requests-toolbelt (>=1.0.0,<2.0.0)", "rspace_client (>=2.5.0,<3.0.0)", "scikit-learn (>=1.2.2,<2.0.0)", "sqlite-vss (>=0.1.2,<0.2.0)", "streamlit (>=1.18.0,<2.0.0)", "sympy (>=1.12,<2.0)", "telethon (>=1.28.5,<2.0.0)", "timescale-vector (>=0.0.1,<0.0.2)", "tqdm (>=4.48.0)", "upstash-redis (>=0.15.0,<0.16.0)", "xata (>=1.0.0a7,<2.0.0)", "xmltodict (>=0.13.0,<0.14.0)"]
|
||||
extended-testing = ["aiosqlite (>=0.19.0,<0.20.0)", "amazon-textract-caller (<2)", "anthropic (>=0.3.11,<0.4.0)", "arxiv (>=1.4,<2.0)", "assemblyai (>=0.17.0,<0.18.0)", "atlassian-python-api (>=3.36.0,<4.0.0)", "beautifulsoup4 (>=4,<5)", "bibtexparser (>=1.4.0,<2.0.0)", "cassio (>=0.1.0,<0.2.0)", "chardet (>=5.1.0,<6.0.0)", "dashvector (>=1.0.1,<2.0.0)", "esprima (>=4.0.1,<5.0.0)", "faiss-cpu (>=1,<2)", "feedparser (>=6.0.10,<7.0.0)", "geopandas (>=0.13.1,<0.14.0)", "gitpython (>=3.1.32,<4.0.0)", "gql (>=3.4.1,<4.0.0)", "html2text (>=2020.1.16,<2021.0.0)", "jinja2 (>=3,<4)", "jq (>=1.4.1,<2.0.0)", "lxml (>=4.9.2,<5.0.0)", "markdownify (>=0.11.6,<0.12.0)", "motor (>=3.3.1,<4.0.0)", "mwparserfromhell (>=0.6.4,<0.7.0)", "mwxml (>=0.3.3,<0.4.0)", "newspaper3k (>=0.2.8,<0.3.0)", "numexpr (>=2.8.6,<3.0.0)", "openai (>=0,<1)", "openapi-schema-pydantic (>=1.2,<2.0)", "pandas (>=2.0.1,<3.0.0)", "pdfminer-six (>=20221105,<20221106)", "pgvector (>=0.1.6,<0.2.0)", "psychicapi (>=0.8.0,<0.9.0)", "py-trello (>=0.19.0,<0.20.0)", "pymupdf (>=1.22.3,<2.0.0)", "pypdf (>=3.4.0,<4.0.0)", "pypdfium2 (>=4.10.0,<5.0.0)", "pyspark (>=3.4.0,<4.0.0)", "rank-bm25 (>=0.2.2,<0.3.0)", "rapidfuzz (>=3.1.1,<4.0.0)", "rapidocr-onnxruntime (>=1.3.2,<2.0.0)", "requests-toolbelt (>=1.0.0,<2.0.0)", "rspace_client (>=2.5.0,<3.0.0)", "scikit-learn (>=1.2.2,<2.0.0)", "sqlite-vss (>=0.1.2,<0.2.0)", "streamlit (>=1.18.0,<2.0.0)", "sympy (>=1.12,<2.0)", "telethon (>=1.28.5,<2.0.0)", "timescale-vector (>=0.0.1,<0.0.2)", "tqdm (>=4.48.0)", "upstash-redis (>=0.15.0,<0.16.0)", "xata (>=1.0.0a7,<2.0.0)", "xmltodict (>=0.13.0,<0.14.0)"]
|
||||
javascript = ["esprima (>=4.0.1,<5.0.0)"]
|
||||
llms = ["clarifai (>=9.1.0)", "cohere (>=4,<5)", "huggingface_hub (>=0,<1)", "manifest-ml (>=0.0.1,<0.0.2)", "nlpcloud (>=1,<2)", "openai (>=0,<1)", "openlm (>=0.0.5,<0.0.6)", "torch (>=1,<3)", "transformers (>=4,<5)"]
|
||||
openai = ["openai (>=0,<1)", "tiktoken (>=0.3.2,<0.6.0)"]
|
||||
@@ -1694,13 +1694,13 @@ text-helpers = ["chardet (>=5.1.0,<6.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.0.47"
|
||||
version = "0.0.44"
|
||||
description = "Client library to connect to the LangSmith LLM Tracing and Evaluation Platform."
|
||||
optional = false
|
||||
python-versions = ">=3.8.1,<4.0"
|
||||
files = [
|
||||
{file = "langsmith-0.0.47-py3-none-any.whl", hash = "sha256:90279d4888e4513f83703becf38a6d92b7c7d696b120872fa1fb28c5c67bfd91"},
|
||||
{file = "langsmith-0.0.47.tar.gz", hash = "sha256:04b8373cbfcf7b9c28ba53174206095c50dc09ae1131a99b501f92776c8ad0c8"},
|
||||
{file = "langsmith-0.0.44-py3-none-any.whl", hash = "sha256:5e7e5b45360ce89a2d5d6066a3b9fdd31b1f874a0cf19b1666c9792fecef0a1b"},
|
||||
{file = "langsmith-0.0.44.tar.gz", hash = "sha256:74a262ba23a958ca1a4863d5386c151be462e40ccfcb8b39d0a5d8c9eaa40164"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2496,13 +2496,13 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale
|
||||
|
||||
[[package]]
|
||||
name = "pytest-mock"
|
||||
version = "3.12.0"
|
||||
version = "3.11.1"
|
||||
description = "Thin-wrapper around the mock package for easier use with pytest"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "pytest-mock-3.12.0.tar.gz", hash = "sha256:31a40f038c22cad32287bb43932054451ff5583ff094bca6f675df2f8bc1a6e9"},
|
||||
{file = "pytest_mock-3.12.0-py3-none-any.whl", hash = "sha256:0972719a7263072da3a21c7f4773069bcc7486027d7e8e1f81d98a47e701bc4f"},
|
||||
{file = "pytest-mock-3.11.1.tar.gz", hash = "sha256:7f6b125602ac6d743e523ae0bfa71e1a697a2f5534064528c6ff84c2f7c2fc7f"},
|
||||
{file = "pytest_mock-3.11.1-py3-none-any.whl", hash = "sha256:21c279fff83d70763b05f8874cc9cfb3fcacd6d354247a976f9529d19f9acf39"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -3328,13 +3328,13 @@ dev = ["flake8", "flake8-annotations", "flake8-bandit", "flake8-bugbear", "flake
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.0.7"
|
||||
version = "2.0.6"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "urllib3-2.0.7-py3-none-any.whl", hash = "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e"},
|
||||
{file = "urllib3-2.0.7.tar.gz", hash = "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84"},
|
||||
{file = "urllib3-2.0.6-py3-none-any.whl", hash = "sha256:7a7c7003b000adf9e7ca2a377c9688bbc54ed41b985789ed576570342a375cd2"},
|
||||
{file = "urllib3-2.0.6.tar.gz", hash = "sha256:b19e1a85d206b56d7df1d5e683df4a7725252a964e3993648dd0fb5a1c157564"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "langserve"
|
||||
version = "0.0.15"
|
||||
version = "0.0.10"
|
||||
description = ""
|
||||
readme = "README.md"
|
||||
authors = ["LangChain"]
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import asyncio
|
||||
import json
|
||||
from asyncio import AbstractEventLoop
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from typing import Any, Dict, Iterator, List, Optional, Sequence, Union
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, Dict, List, Optional, TypedDict, Union
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -15,13 +15,11 @@ from langchain.callbacks.tracers.log_stream import RunLogPatch
|
||||
from langchain.prompts import PromptTemplate
|
||||
from langchain.prompts.base import StringPromptValue
|
||||
from langchain.schema.messages import HumanMessage, SystemMessage
|
||||
from langchain.schema.runnable import Runnable, RunnableConfig, RunnablePassthrough
|
||||
from langchain.schema.runnable import RunnableConfig, RunnablePassthrough
|
||||
from langchain.schema.runnable.base import RunnableLambda
|
||||
from langchain.schema.runnable.utils import ConfigurableField, Input, Output
|
||||
from langchain.schema.runnable.utils import ConfigurableField
|
||||
from pytest_mock import MockerFixture
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langserve import server
|
||||
from langserve.client import RemoteRunnable
|
||||
from langserve.lzstring import LZString
|
||||
from langserve.server import (
|
||||
@@ -104,19 +102,14 @@ def client(app: FastAPI) -> RemoteRunnable:
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_async_client(
|
||||
server: FastAPI, *, path: Optional[str] = None, raise_app_exceptions: bool = True
|
||||
server: FastAPI, path: Optional[str] = None
|
||||
) -> RemoteRunnable:
|
||||
"""Get an async client."""
|
||||
url = "http://localhost:9999"
|
||||
if path:
|
||||
url += path
|
||||
remote_runnable_client = RemoteRunnable(url=url)
|
||||
|
||||
transport = httpx.ASGITransport(
|
||||
app=server,
|
||||
raise_app_exceptions=raise_app_exceptions,
|
||||
)
|
||||
async_client = AsyncClient(app=server, base_url=url, transport=transport)
|
||||
async_client = AsyncClient(app=server, base_url=url)
|
||||
remote_runnable_client.async_client = async_client
|
||||
try:
|
||||
yield remote_runnable_client
|
||||
@@ -124,25 +117,6 @@ async def get_async_client(
|
||||
await async_client.aclose()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_sync_client(
|
||||
server: FastAPI, *, path: Optional[str] = None, raise_server_exceptions: bool = True
|
||||
) -> RemoteRunnable:
|
||||
"""Get an async client."""
|
||||
url = "http://localhost:9999"
|
||||
if path:
|
||||
url += path
|
||||
remote_runnable_client = RemoteRunnable(url=url)
|
||||
sync_client = TestClient(
|
||||
app=server, base_url=url, raise_server_exceptions=raise_server_exceptions
|
||||
)
|
||||
remote_runnable_client.sync_client = sync_client
|
||||
try:
|
||||
yield remote_runnable_client
|
||||
finally:
|
||||
sync_client.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
async def async_client(app: FastAPI) -> RemoteRunnable:
|
||||
"""Create a FastAPI app that exposes the Runnable as an API."""
|
||||
@@ -774,132 +748,6 @@ def test_rename_pydantic_model() -> None:
|
||||
assert Model.__name__ == "Bar"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_input_config_output_schemas(event_loop: AbstractEventLoop) -> None:
|
||||
"""Test schemas returned for different configurations."""
|
||||
# TODO(Fix me): need to fix handling of global state -- we get problems
|
||||
# gives inconsistent results when running multiple tests / results
|
||||
# depending on ordering
|
||||
server._SEEN_NAMES = set()
|
||||
server._MODEL_REGISTRY = {}
|
||||
|
||||
async def add_one(x: int) -> int:
|
||||
"""Add one to simulate a valid function"""
|
||||
return x + 1
|
||||
|
||||
async def add_two(y: int) -> int:
|
||||
"""Add one to simulate a valid function"""
|
||||
return y + 2
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
add_routes(app, RunnableLambda(add_one), path="/add_one")
|
||||
# Custom input type
|
||||
add_routes(
|
||||
app,
|
||||
RunnableLambda(add_two),
|
||||
path="/add_two_custom",
|
||||
input_type=float,
|
||||
output_type=Sequence[float],
|
||||
config_keys=["tags", "configurable"],
|
||||
)
|
||||
add_routes(app, PromptTemplate.from_template("{question}"), path="/prompt_1")
|
||||
|
||||
template = PromptTemplate.from_template("say {name}").configurable_fields(
|
||||
template=ConfigurableField(
|
||||
id="template",
|
||||
name="Template",
|
||||
description="The template to use for the prompt",
|
||||
)
|
||||
)
|
||||
add_routes(app, template, path="/prompt_2", config_keys=["tags", "configurable"])
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://localhost:9999") as async_client:
|
||||
# input schema
|
||||
response = await async_client.get("/add_one/input_schema")
|
||||
assert response.json() == {"title": "RunnableLambdaInput", "type": "integer"}
|
||||
|
||||
response = await async_client.get("/add_two_custom/input_schema")
|
||||
assert response.json() == {"title": "Input", "type": "number"}
|
||||
|
||||
response = await async_client.get("/prompt_1/input_schema")
|
||||
assert response.json() == {
|
||||
"properties": {"question": {"title": "Question", "type": "string"}},
|
||||
"title": "PromptInput",
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
response = await async_client.get("/prompt_2/input_schema")
|
||||
assert response.json() == {
|
||||
"properties": {"name": {"title": "Name", "type": "string"}},
|
||||
"title": "PromptInput",
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
# output schema
|
||||
response = await async_client.get("/add_one/output_schema")
|
||||
assert response.json() == {
|
||||
"title": "RunnableLambdaOutput",
|
||||
"type": "integer",
|
||||
}
|
||||
|
||||
response = await async_client.get("/add_two_custom/output_schema")
|
||||
assert response.json() == {
|
||||
"items": {"type": "number"},
|
||||
"title": "Output",
|
||||
"type": "array",
|
||||
}
|
||||
|
||||
# Just verify that the schema is not empty (it's pretty long)
|
||||
# and the actual value should be tested in LangChain
|
||||
response = await async_client.get("/prompt_1/output_schema")
|
||||
assert response.json() != {} # Long string
|
||||
|
||||
response = await async_client.get("/prompt_2/output_schema")
|
||||
assert response.json() != {} # Long string
|
||||
|
||||
## Config schema
|
||||
response = await async_client.get("/add_one/config_schema")
|
||||
assert response.json() == {
|
||||
"properties": {},
|
||||
"title": "RunnableLambdaConfig",
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
response = await async_client.get("/add_two_custom/config_schema")
|
||||
assert response.json() == {
|
||||
"properties": {
|
||||
"tags": {"items": {"type": "string"}, "title": "Tags", "type": "array"}
|
||||
},
|
||||
"title": "RunnableLambdaConfig",
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
response = await async_client.get("/prompt_2/config_schema")
|
||||
assert response.json() == {
|
||||
"definitions": {
|
||||
"Configurable": {
|
||||
"properties": {
|
||||
"template": {
|
||||
"default": "say {name}",
|
||||
"description": "The template to use for the prompt",
|
||||
"title": "Template",
|
||||
"type": "string",
|
||||
}
|
||||
},
|
||||
"title": "Configurable",
|
||||
"type": "object",
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"configurable": {"$ref": "#/definitions/Configurable"},
|
||||
"tags": {"items": {"type": "string"}, "title": "Tags", "type": "array"},
|
||||
},
|
||||
"title": "RunnableConfigurableFieldsConfig",
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_input_schema_typed_dict() -> None:
|
||||
class InputType(TypedDict):
|
||||
@@ -934,97 +782,3 @@ async def test_input_schema_typed_dict() -> None:
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ErroringRunnable(Runnable):
|
||||
"""A custom runnable for testing errors are raised server side."""
|
||||
|
||||
def invoke(self, input: Input, config: Optional[RunnableConfig] = None) -> Output:
|
||||
"""Invoke the runnable."""
|
||||
raise ValueError("Server side error")
|
||||
|
||||
def stream(
|
||||
self,
|
||||
input: Input,
|
||||
config: Optional[RunnableConfig] = None,
|
||||
**kwargs: Optional[Any],
|
||||
) -> Iterator[Output]:
|
||||
yield 1
|
||||
yield 2
|
||||
raise ValueError("An exception occurred")
|
||||
|
||||
async def astream(
|
||||
self,
|
||||
input: Iterator[Input],
|
||||
config: Optional[RunnableConfig] = None,
|
||||
**kwargs: Optional[Any],
|
||||
) -> Iterator[Output]:
|
||||
yield 1
|
||||
yield 2
|
||||
raise ValueError("An exception occurred")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_side_error() -> None:
|
||||
"""Test server side error handling."""
|
||||
|
||||
app = FastAPI()
|
||||
add_routes(app, ErroringRunnable())
|
||||
|
||||
# Invoke request
|
||||
async with get_async_client(app, raise_app_exceptions=False) as runnable:
|
||||
with pytest.raises(httpx.HTTPStatusError) as cm:
|
||||
assert await runnable.ainvoke(1)
|
||||
assert isinstance(cm.value, httpx.HTTPStatusError)
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as cm:
|
||||
assert await runnable.abatch([1, 2])
|
||||
assert isinstance(cm.value, httpx.HTTPStatusError)
|
||||
|
||||
# Test astream
|
||||
chunks = []
|
||||
try:
|
||||
async for chunk in runnable.astream({"a": 1}):
|
||||
chunks.append(chunk)
|
||||
except httpx.HTTPStatusError as e:
|
||||
assert chunks == [1, 2]
|
||||
assert e.response.status_code == 500
|
||||
assert e.response.text == "Internal Server Error"
|
||||
|
||||
# # Failing right now, can uncomment or add callbacks
|
||||
# # Test astream_log
|
||||
# chunks = []
|
||||
# try:
|
||||
# async for chunk in runnable.astream_log({"a": 1}):
|
||||
# chunks.append(chunk)
|
||||
# except httpx.HTTPStatusError as e:
|
||||
# assert chunks == []
|
||||
# assert e.response.status_code == 500
|
||||
# assert e.response.text == "Internal Server Error"
|
||||
|
||||
|
||||
def test_server_side_error_sync() -> None:
|
||||
"""Test server side error handling."""
|
||||
|
||||
app = FastAPI()
|
||||
add_routes(app, ErroringRunnable())
|
||||
|
||||
# Invoke request
|
||||
with get_sync_client(app, raise_server_exceptions=False) as runnable:
|
||||
with pytest.raises(httpx.HTTPStatusError) as cm:
|
||||
assert runnable.invoke(1)
|
||||
assert isinstance(cm.value, httpx.HTTPStatusError)
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as cm:
|
||||
assert runnable.batch([1, 2])
|
||||
assert isinstance(cm.value, httpx.HTTPStatusError)
|
||||
|
||||
# Test astream
|
||||
chunks = []
|
||||
try:
|
||||
for chunk in runnable.stream({"a": 1}):
|
||||
chunks.append(chunk)
|
||||
except httpx.HTTPStatusError as e:
|
||||
assert chunks == [1, 2]
|
||||
assert e.response.status_code == 500
|
||||
assert e.response.text == "Internal Server Error"
|
||||
|
||||
Reference in New Issue
Block a user