Compare commits
5 Commits
1993aabeac
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 3093b41033 | |||
| a64ae78833 | |||
| b558ef8a7f | |||
| f1e6c010eb | |||
| 124f215a0a |
113
.github/workflows/release.yml
vendored
@ -6,68 +6,37 @@ on:
|
|||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
linux:
|
||||||
strategy:
|
runs-on: ubuntu-latest
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- platform: ubuntu-24.04
|
|
||||||
target: x86_64-unknown-linux-gnu
|
|
||||||
bundles: deb
|
|
||||||
- platform: ubuntu-24.04
|
|
||||||
target: x86_64-unknown-linux-gnu
|
|
||||||
bundles: rpm
|
|
||||||
- platform: ubuntu-24.04
|
|
||||||
target: x86_64-unknown-linux-gnu
|
|
||||||
bundles: archlinux
|
|
||||||
- platform: windows-latest
|
|
||||||
target: x86_64-pc-windows-msvc
|
|
||||||
bundles: msi
|
|
||||||
|
|
||||||
runs-on: ${{ matrix.platform }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
- run: npm ci
|
||||||
- name: Install frontend dependencies
|
|
||||||
run: npm ci
|
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- name: Install Rust
|
- run: |
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
targets: ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Install system dependencies (Linux)
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
libwebkit2gtk-4.1-dev \
|
libwebkit2gtk-4.1-dev \
|
||||||
libappindicator3-dev \
|
|
||||||
librsvg2-dev \
|
librsvg2-dev \
|
||||||
patchelf \
|
patchelf \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
libgtk-3-dev \
|
libgtk-3-dev \
|
||||||
libayatana-appindicator3-dev
|
libayatana-appindicator3-dev \
|
||||||
|
rpm
|
||||||
- name: Install RPM build tools
|
- name: Download FFmpeg (bundled sidecar)
|
||||||
if: matrix.bundles == 'rpm'
|
run: |
|
||||||
run: sudo apt-get install -y rpm
|
mkdir -p src-tauri/binaries
|
||||||
|
curl -sL "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" -o /tmp/ffmpeg.tar.xz
|
||||||
- name: Install ArchLinux build tools
|
tar -xf /tmp/ffmpeg.tar.xz -C /tmp
|
||||||
if: matrix.bundles == 'archlinux'
|
cp /tmp/ffmpeg-*-amd64-static/ffmpeg src-tauri/binaries/ffmpeg-x86_64-unknown-linux-gnu
|
||||||
run: sudo apt-get install -y pacman-pkg-strap
|
cp /tmp/ffmpeg-*-amd64-static/ffprobe src-tauri/binaries/ffprobe-x86_64-unknown-linux-gnu
|
||||||
|
chmod +x src-tauri/binaries/*
|
||||||
- name: Build Tauri app
|
- uses: tauri-apps/tauri-action@v0
|
||||||
uses: tauri-apps/tauri-action@v0
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
@ -76,4 +45,50 @@ jobs:
|
|||||||
releaseBody: 'See the assets to download and install this version.'
|
releaseBody: 'See the assets to download and install this version.'
|
||||||
releaseDraft: false
|
releaseDraft: false
|
||||||
includeUpdaterJson: true
|
includeUpdaterJson: true
|
||||||
args: --bundles ${{ matrix.bundles }} --target ${{ matrix.target }}
|
args: --bundles deb,rpm
|
||||||
|
|
||||||
|
windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
- run: npm ci
|
||||||
|
working-directory: frontend
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
tagName: ${{ github.ref_name }}
|
||||||
|
releaseName: 'TalkEdit ${{ github.ref_name }}'
|
||||||
|
releaseBody: 'See the assets to download and install this version.'
|
||||||
|
releaseDraft: false
|
||||||
|
includeUpdaterJson: true
|
||||||
|
args: --bundles msi
|
||||||
|
|
||||||
|
# macos:
|
||||||
|
# runs-on: macos-latest
|
||||||
|
# steps:
|
||||||
|
# - uses: actions/checkout@v4
|
||||||
|
# - uses: actions/setup-node@v4
|
||||||
|
# with:
|
||||||
|
# node-version: 20
|
||||||
|
# cache: npm
|
||||||
|
# cache-dependency-path: frontend/package-lock.json
|
||||||
|
# - run: npm ci
|
||||||
|
# working-directory: frontend
|
||||||
|
# - uses: dtolnay/rust-toolchain@stable
|
||||||
|
# - uses: tauri-apps/tauri-action@v0
|
||||||
|
# env:
|
||||||
|
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# with:
|
||||||
|
# tagName: ${{ github.ref_name }}
|
||||||
|
# releaseName: 'TalkEdit ${{ github.ref_name }}'
|
||||||
|
# releaseBody: 'See the assets to download and install this version.'
|
||||||
|
# releaseDraft: false
|
||||||
|
# includeUpdaterJson: true
|
||||||
|
# args: --bundles dmg
|
||||||
|
|||||||
54
backend/routers/local_llm.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"""Local LLM endpoints for bundled Qwen3 inference."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from services.local_llm import get_status, download_model, complete
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class CompleteRequest(BaseModel):
|
||||||
|
prompt: str
|
||||||
|
model_id: str = "qwen3-1.7b"
|
||||||
|
system_prompt: Optional[str] = None
|
||||||
|
temperature: float = 0.3
|
||||||
|
max_tokens: int = 2048
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/local-llm/status")
|
||||||
|
async def llm_status():
|
||||||
|
try:
|
||||||
|
return get_status()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Local LLM status failed: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/local-llm/download")
|
||||||
|
async def llm_download(model_id: str = "qwen3-1.7b"):
|
||||||
|
try:
|
||||||
|
return download_model(model_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Local LLM download failed: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/local-llm/complete")
|
||||||
|
async def llm_complete(req: CompleteRequest):
|
||||||
|
try:
|
||||||
|
result = complete(
|
||||||
|
prompt=req.prompt,
|
||||||
|
model_id=req.model_id,
|
||||||
|
system_prompt=req.system_prompt,
|
||||||
|
temperature=req.temperature,
|
||||||
|
max_tokens=req.max_tokens,
|
||||||
|
)
|
||||||
|
return {"response": result}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Local LLM completion failed: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
125
backend/services/local_llm.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"""
|
||||||
|
Local LLM inference using llama.cpp via llama-cpp-python.
|
||||||
|
Handles model download from HuggingFace and text completion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LOCAL_MODELS_DIR = Path.home() / ".cache" / "talkedit" / "models"
|
||||||
|
QWEN_MODELS = {
|
||||||
|
"qwen3-1.7b": {
|
||||||
|
"repo": "Qwen/Qwen3-1.7B-Instruct-GGUF",
|
||||||
|
"file": "qwen3-1.7b-instruct-q4_k_m.gguf",
|
||||||
|
"size_gb": 1.0,
|
||||||
|
},
|
||||||
|
"qwen3-4b": {
|
||||||
|
"repo": "Qwen/Qwen3-4B-Instruct-GGUF",
|
||||||
|
"file": "qwen3-4b-instruct-q4_k_m.gguf",
|
||||||
|
"size_gb": 2.5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_llama_cpp() -> bool:
|
||||||
|
try:
|
||||||
|
from llama_cpp import Llama
|
||||||
|
return True
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _model_path(model_id: str) -> Path:
|
||||||
|
info = QWEN_MODELS.get(model_id)
|
||||||
|
if not info:
|
||||||
|
raise ValueError(f"Unknown model: {model_id}")
|
||||||
|
return LOCAL_MODELS_DIR / model_id / info["file"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_status() -> dict:
|
||||||
|
"""Check status of local LLM setup."""
|
||||||
|
llama_available = _ensure_llama_cpp()
|
||||||
|
models = {}
|
||||||
|
for model_id in QWEN_MODELS:
|
||||||
|
path = _model_path(model_id)
|
||||||
|
models[model_id] = {
|
||||||
|
"downloaded": path.exists(),
|
||||||
|
"size_bytes": path.stat().st_size if path.exists() else 0,
|
||||||
|
"total_gb": QWEN_MODELS[model_id]["size_gb"],
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"llama_cpp_available": llama_available,
|
||||||
|
"models": models,
|
||||||
|
"models_dir": str(LOCAL_MODELS_DIR),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def download_model(model_id: str) -> dict:
|
||||||
|
"""Download a Qwen3 GGUF model from HuggingFace."""
|
||||||
|
info = QWEN_MODELS.get(model_id)
|
||||||
|
if not info:
|
||||||
|
raise ValueError(f"Unknown model: {model_id}")
|
||||||
|
|
||||||
|
model_dir = LOCAL_MODELS_DIR / model_id
|
||||||
|
model_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path = model_dir / info["file"]
|
||||||
|
|
||||||
|
if output_path.exists():
|
||||||
|
return {"status": "already_downloaded", "path": str(output_path)}
|
||||||
|
|
||||||
|
logger.info(f"Downloading {info['repo']}/{info['file']} ({info['size_gb']} GB)...")
|
||||||
|
subprocess.run([
|
||||||
|
sys.executable, "-m", "huggingface_hub", "download",
|
||||||
|
info["repo"], info["file"],
|
||||||
|
"--local-dir", str(model_dir),
|
||||||
|
"--local-dir-use-symlinks", "False",
|
||||||
|
], check=True)
|
||||||
|
|
||||||
|
if not output_path.exists():
|
||||||
|
raise RuntimeError(f"Download failed: {output_path} not found")
|
||||||
|
|
||||||
|
return {"status": "downloaded", "path": str(output_path)}
|
||||||
|
|
||||||
|
|
||||||
|
def complete(
|
||||||
|
prompt: str,
|
||||||
|
model_id: str = "qwen3-1.7b",
|
||||||
|
system_prompt: Optional[str] = None,
|
||||||
|
temperature: float = 0.3,
|
||||||
|
max_tokens: int = 2048,
|
||||||
|
) -> str:
|
||||||
|
"""Run inference using a local Qwen3 model."""
|
||||||
|
model_path = _model_path(model_id)
|
||||||
|
if not model_path.exists():
|
||||||
|
raise RuntimeError(f"Model not downloaded: {model_id}")
|
||||||
|
|
||||||
|
from llama_cpp import Llama
|
||||||
|
|
||||||
|
llm = Llama(
|
||||||
|
model_path=str(model_path),
|
||||||
|
n_ctx=4096,
|
||||||
|
n_threads=4,
|
||||||
|
n_gpu_layers=-1,
|
||||||
|
verbose=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
if system_prompt:
|
||||||
|
messages.append({"role": "system", "content": system_prompt})
|
||||||
|
messages.append({"role": "user", "content": prompt})
|
||||||
|
|
||||||
|
response = llm.create_chat_completion(
|
||||||
|
messages=messages,
|
||||||
|
temperature=temperature,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
return response["choices"][0]["message"]["content"].strip()
|
||||||
44
docs/gitea-runner-windows.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Gitea Runner — Windows Laptop
|
||||||
|
|
||||||
|
Self-hosted runner registered as `windows-laptop` with label `windows-latest`.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Download
|
||||||
|
Invoke-WebRequest -Uri "https://gitea.com/gitea/runner/releases/download/v1.0.1/gitea-runner-1.0.1-windows-amd64.exe" -OutFile "$env:USERPROFILE\gitea-runner-windows-amd64.exe"
|
||||||
|
|
||||||
|
# Register (Admin PowerShell)
|
||||||
|
.\gitea-runner-windows-amd64.exe register --instance http://143.244.157.110:3000 --token NS5LXzLzNOvPKD9Id4SrLQ09bReHOrn6T2c4EyGM --name windows-laptop --labels windows-latest --no-interactive
|
||||||
|
|
||||||
|
# Start (foreground)
|
||||||
|
.\gitea-runner-windows-amd64.exe daemon
|
||||||
|
|
||||||
|
# Install as Windows service (auto-starts on boot)
|
||||||
|
.\gitea-runner-windows-amd64.exe service install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
### Workflow job logs (step output)
|
||||||
|
|
||||||
|
Stored on the Gitea server (not locally). Download from:
|
||||||
|
`http://143.244.157.110:3000/<owner>/<repo>/actions/runs/<run_id>`
|
||||||
|
|
||||||
|
Click a job, then the **Download log** button at the top-right.
|
||||||
|
|
||||||
|
### Runner daemon logs (runner itself)
|
||||||
|
|
||||||
|
| Mode | Log location |
|
||||||
|
|---|---|
|
||||||
|
| Foreground (`daemon`) | PowerShell console stdout |
|
||||||
|
| Windows service (`service install`) | `%ProgramData%\gitea-runner\log\` or Windows Event Viewer → Windows Logs → Application |
|
||||||
|
|
||||||
|
## Diagnostics
|
||||||
|
|
||||||
|
If a CI job fails, download the full log from the Gitea Actions UI (as above), then search for the first error:
|
||||||
|
|
||||||
|
- **Rust**: look for `error[E...]`, `error: could not compile`, or `cargo test` failures
|
||||||
|
- **Python**: look for `FAILED`, `AssertionError`, or `ModuleNotFoundError`
|
||||||
|
|
||||||
|
The runner's own logs (`daemon` mode) will show which job it picked up, container lifecycle, and any infrastructure issues (disk full, Docker unavailable, etc.).
|
||||||
1
src-tauri/.gitignore
vendored
@ -2,3 +2,4 @@
|
|||||||
# will have compiled files and executables
|
# will have compiled files and executables
|
||||||
/target/
|
/target/
|
||||||
/gen/schemas
|
/gen/schemas
|
||||||
|
binaries/
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 719 B |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 629 B |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 922 B |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 968 B |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 10 KiB |
1
src-tauri/icons/icon.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg width="48" height="48" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 10h12a6 6 0 0 1 0 12H8l-2 4V10Z" stroke="#818cf8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity="0.7"></path><path d="M6 10h12a6 6 0 0 1 0 12H8l-2 4V10Z" stroke="#6366f1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path><path d="M10 14v4M13 13v6M16 14v4" stroke="#6366f1" stroke-width="1.5" stroke-linecap="round"></path><path d="M22 16h6M22 19h4M22 22h5" stroke="#818cf8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.6"></path></svg>
|
||||||
|
After Width: | Height: | Size: 622 B |
@ -397,6 +397,15 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add bundled ffmpeg/ffprobe to PATH so Python subprocesses find them
|
||||||
|
if let Some(ffmpeg_dir) = paths::bundled_ffmpeg().and_then(|p| p.parent().map(|p| p.to_path_buf())) {
|
||||||
|
if let Ok(current_path) = std::env::var("PATH") {
|
||||||
|
let new_path = format!("{}:{}", ffmpeg_dir.display(), current_path);
|
||||||
|
std::env::set_var("PATH", &new_path);
|
||||||
|
}
|
||||||
|
log::info!("Added bundled FFmpeg directory to PATH: {}", ffmpeg_dir.display());
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
|||||||
@ -62,7 +62,38 @@ pub fn backend_script(name: &str) -> PathBuf {
|
|||||||
project_root().join("backend").join(name)
|
project_root().join("backend").join(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Absolute path to a script at the project root.
|
/// Locate bundled ffmpeg binary (sidecar).
|
||||||
|
pub fn bundled_ffmpeg() -> Option<PathBuf> {
|
||||||
|
let exe = std::env::current_exe().ok()?;
|
||||||
|
let dir = exe.parent()?;
|
||||||
|
// Tauri places externalBin next to the executable
|
||||||
|
let candidates = [
|
||||||
|
dir.join("ffmpeg"),
|
||||||
|
dir.join("binaries").join("ffmpeg"),
|
||||||
|
];
|
||||||
|
for c in &candidates {
|
||||||
|
if c.exists() {
|
||||||
|
return Some(c.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locate bundled ffprobe binary (sidecar).
|
||||||
|
pub fn bundled_ffprobe() -> Option<PathBuf> {
|
||||||
|
let exe = std::env::current_exe().ok()?;
|
||||||
|
let dir = exe.parent()?;
|
||||||
|
let candidates = [
|
||||||
|
dir.join("ffprobe"),
|
||||||
|
dir.join("binaries").join("ffprobe"),
|
||||||
|
];
|
||||||
|
for c in &candidates {
|
||||||
|
if c.exists() {
|
||||||
|
return Some(c.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
pub fn root_script(name: &str) -> PathBuf {
|
pub fn root_script(name: &str) -> PathBuf {
|
||||||
project_root().join(name)
|
project_root().join(name)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,10 @@
|
|||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"externalBin": [
|
||||||
|
"binaries/ffmpeg",
|
||||||
|
"binaries/ffprobe"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||